Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/berneyiscool-debug_relay-dispatch.json

1 line
8.0 MiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "berneyiscool-debug/relay-dispatch", "files": {"src/data/store.js": {"0227a0cd7bffdad9acb2241451005b35f9985c7c": {"t": 1785745555.3606825, "n": 119827, "h": "85e68cfce46fde92", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\nconst defaultLogoLarge = new URL('../assets/RELAY_Dispatch_Logo.png', import.meta.url).href;\nconst defaultLogoSmall = new URL('../assets/logo-small.png', import.meta.url).href;\n\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n passwordResetRequests: 'password_reset_requests',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents',\n leads: 'leads',\n schedule: 'schedule',\n projects: 'projects',\n costCenters: 'cost_centers',\n deputyAsks: null\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"deactivated\",\n \"deactivated_at\",\n \"start_location\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"project_id\",\n \"cost_center_id\",\n \"created_at\",\n \"updated_at\"\n ],\n projects: [\n \"id\",\n \"company_id\",\n \"number\",\n \"name\",\n \"customer_id\",\n \"customer_name\",\n \"site_address\",\n \"status\",\n \"description\",\n \"start_date\",\n \"end_date\",\n \"created_at\",\n \"updated_at\"\n ],\n costCenters: [\n \"id\",\n \"company_id\",\n \"name\",\n \"code\",\n \"active\",\n \"xero_sales_account_code\",\n \"xero_expense_account_code\",\n \"xero_tracking_category_name\",\n \"xero_tracking_option_name\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"paid_date\",\n \"payment_method\",\n \"stripe_session_id\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"license_number\",\n \"hourly_rate\",\n \"after_hours_rate\",\n \"callout_fee\",\n \"specialties\",\n \"notes\",\n \"portal_token\",\n \"compliance_docs\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"address\",\n \"category\",\n \"account_number\",\n \"payment_terms\",\n \"notes\",\n \"attachments\",\n \"created_at\",\n \"updated_at\"\n ],\n leads: [\n \"id\",\n \"company_id\",\n \"number\",\n \"title\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"status\",\n \"source\",\n \"value\",\n \"description\",\n \"priority\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"created_at\",\n \"updated_at\"\n ],\n password_reset_requests: [\n \"id\",\n \"technician_id\",\n \"employee_id\",\n \"requested_at\",\n \"status\",\n \"token\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"type\",\n \"description\",\n \"priority\",\n \"read\",\n \"number\",\n \"asset_id\",\n \"job_id\",\n \"due_date\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"type\",\n \"date\",\n \"status\",\n \"notes\",\n \"start_time\",\n \"finish_time\",\n \"hours\",\n \"task_id\",\n \"task_name\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g><text x=\"90\" y=\"36\" fill=\"#2D3134\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"25\" font-weight=\"900\" letter-spacing=\"0.04em\">DISPATCH</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nconst MODULE_PERMS = {\n 'Dashboard': [{ key: 'view' }],\n 'Customers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_contacts' }],\n 'Leads': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'convert' }],\n 'Quotes': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'approve' }, { key: 'convert' }, { key: 'generate_pdf' }],\n 'Jobs': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_tasks' }, { key: 'book_time' }, { key: 'view_costs' }, { key: 'view_quotes_tab' }, { key: 'view_pos_tab' }, { key: 'view_timesheets_tab' }, { key: 'view_invoices_tab' }, { key: 'manage_materials' }, { key: 'create_invoice' }],\n 'Timesheets': [{ key: 'view_own' }, { key: 'view' }, { key: 'create' }, { key: 'approve' }, { key: 'edit_all' }, { key: 'export' }],\n 'Assets': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Schedule': [{ key: 'view_own' }, { key: 'view' }, { key: 'edit' }],\n 'Contractors': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }],\n 'Suppliers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Stock': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Purchase Orders': [{ key: 'view' }, { key: 'create' }, { key: 'approve' }],\n 'Invoices': [{ key: 'view' }, { key: 'create' }, { key: 'send' }, { key: 'void' }],\n 'Reports': [{ key: 'view' }, { key: 'export' }],\n 'Documents': [{ key: 'view' }, { key: 'upload' }],\n 'Settings': [{ key: 'view' }, { key: 'edit_company' }, { key: 'manage_users' }, { key: 'manage_tax' }]\n};\n\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n this.db = null;\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n this.backupDirHandle = null;\n this.backupDirPermissionGranted = false;\n\n // Check if we have a cloud user at boot (before filling cache)\n const bootUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n const isCloudUser = !!(bootUser && bootUser.companyId && !bootUser.companyId.startsWith('acct_'));\n\n if (!isCloudUser && bootUser && bootUser.companyId && typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', bootUser.companyId);\n }\n\n // Pre-initialize empty arrays for collections.\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Load local settings if they exist (only for offline/demo mode)\n if (!isCloudUser && typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId && !this.companyId.startsWith('acct_')) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n // Only clear sync if we were previously in cloud sync mode or companyId is not defined\n if (!this.companyId || !this.companyId.startsWith('acct_')) {\n this.clearSync();\n }\n }\n });\n\n // Auto-trigger sync or local load if user already logged in at boot\n if (isCloudUser) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n this.initPromise = this.initializeCloudSync();\n } else {\n if (bootUser && bootUser.companyId) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n }\n this.initPromise = this.initializeLocalStore();\n }\n }\n\n getStorageKey(collection) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (activeAccount) {\n return `relay_${activeAccount}_${collection}`;\n }\n return `simpro_${collection}`;\n }\n\n getDBName() {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n return activeAccount ? `RelayDispatchDB_${activeAccount}` : 'RelayDispatchDB';\n }\n\n async initializeUser(user) {\n if (user && user.companyId && !user.companyId.startsWith('acct_')) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n } else {\n this.clearSync();\n if (user && user.companyId) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', user.companyId);\n }\n } else {\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n }\n this.initPromise = this.initializeLocalStore();\n await this.initPromise;\n }\n }\n\n async initializeLocalStore() {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n\n try {\n await this.initIndexedDB();\n } catch (e) {\n console.error('Failed to initialize IndexedDB, falling back to empty arrays:', e);\n }\n\n // Load local settings if they exist (namespaced)\n this.companySettings = null;\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n this.emit('settings', this.getSettings());\n\n // Seed default user types and forms if empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n if (this.cache.technicians.length === 0) {\n await this.seedDefaultTechnicians();\n }\n }\n\n initIndexedDB() {\n return new Promise((resolve, reject) => {\n if (typeof window === 'undefined' || !window.indexedDB) {\n return resolve();\n }\n\n const dbName = this.getDBName();\n const request = window.indexedDB.open(dbName, 4);\n\n request.onerror = (e) => {\n console.error('IndexedDB open error:', e.target.error);\n reject(e.target.error);\n };\n\n request.onsuccess = async (e) => {\n this.db = e.target.result;\n \n // 1. Load custom directory sync config from IndexedDB config store if it exists\n try {\n if (this.db.objectStoreNames.contains('config')) {\n await new Promise((resConfig) => {\n const transaction = this.db.transaction('config', 'readonly');\n const configStore = transaction.objectStore('config');\n \n const getSync = new Promise((resolve) => {\n const req = configStore.get('directory_handle');\n req.onsuccess = () => {\n console.log('[DEBUG Store] getSync onsuccess result:', req.result);\n if (req.result) {\n this.dirHandle = req.result.value;\n console.log('[DEBUG Store] loaded dirHandle:', this.dirHandle ? this.dirHandle.name : null);\n }\n resolve();\n };\n req.onerror = (e) => {\n console.error('[DEBUG Store] getSync onerror:', e);\n resolve();\n };\n });\n\n const getBackup = new Promise((resolve) => {\n const req = configStore.get('backup_directory_handle');\n req.onsuccess = () => {\n if (req.result) this.backupDirHandle = req.result.value;\n resolve();\n };\n req.onerror = () => resolve();\n });\n\n Promise.all([getSync, getBackup]).then(() => resConfig());\n });\n }\n } catch (err) {\n console.warn('Failed to read config from IndexedDB:', err);\n }\n\n // Verify directory permission silently on boot if handle exists\n if (this.dirHandle) {\n try {\n // Check permission silently without prompt dialog\n const opts = { mode: 'readwrite' };\n const status = await this.dirHandle.queryPermission(opts);\n this.folderSyncPermissionGranted = status === 'granted';\n } catch (err) {\n console.warn('Failed to query directory permission on boot:', err);\n }\n }\n\n // Verify backup directory permission silently on boot if handle exists\n if (this.backupDirHandle) {\n try {\n const opts = { mode: 'readwrite' };\n const status = await this.backupDirHandle.queryPermission(opts);\n this.backupDirPermissionGranted = status === 'granted';\n\n // Auto-trigger backup if granted and > 7 days since last backup\n if (this.backupDirPermissionGranted) {\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;\n if (!lastBackup || new Date(lastBackup).getTime() < sevenDaysAgo) {\n this.backupToFolder().catch(err => {\n console.error('Auto background backup failed:', err);\n });\n }\n }\n } catch (err) {\n console.warn('Failed to query backup directory permission on boot:', err);\n }\n }\n\n // Load all collections into cache\n const collections = Object.keys(TABLE_MAP);\n const loadPromises = collections.map(async (col) => {\n try {\n this.cache[col] = await this.readAllFromIndexedDB(col);\n } catch (err) {\n console.error(`Error loading collection ${col} from IndexedDB:`, err);\n this.cache[col] = [];\n }\n });\n\n await Promise.all(loadPromises);\n\n // Perform migration if we have old data in localStorage (only if not in a namespaced local account profile)\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (!activeAccount) {\n const migratePromises = collections.map(async (col) => {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n const items = JSON.parse(localData);\n if (Array.isArray(items) && items.length > 0) {\n // Write all items to IndexedDB\n await this.writeAllToIndexedDB(col, items);\n // Merge into cache if cache is empty\n if (this.cache[col].length === 0) {\n this.cache[col] = items;\n }\n }\n localStorage.removeItem('simpro_' + col);\n } catch (err) {\n console.error(`Migration error for ${col}:`, err);\n }\n }\n });\n\n await Promise.all(migratePromises);\n }\n resolve();\n };\n\n request.onupgradeneeded = (e) => {\n const db = e.target.result;\n const collections = Object.keys(TABLE_MAP);\n collections.forEach(col => {\n if (!db.objectStoreNames.contains(col)) {\n db.createObjectStore(col, { keyPath: 'id' });\n }\n });\n if (!db.objectStoreNames.contains('config')) {\n db.createObjectStore('config', { keyPath: 'key' });\n }\n };\n });\n }\n\n readAllFromIndexedDB(storeName) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve([]);\n try {\n const transaction = this.db.transaction(storeName, 'readonly');\n const store = transaction.objectStore(storeName);\n const getAllRequest = store.getAll();\n\n getAllRequest.onsuccess = () => {\n resolve(getAllRequest.result || []);\n };\n\n getAllRequest.onerror = (e) => {\n reject(e.target.error);\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeRecordToIndexedDB(storeName, record) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const putRequest = store.put(record);\n\n putRequest.onsuccess = () => resolve();\n putRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n deleteRecordFromIndexedDB(storeName, id) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const deleteRequest = store.delete(id);\n\n deleteRequest.onsuccess = () => resolve();\n deleteRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeAllToIndexedDB(storeName, items) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n \n // Clear existing\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n \n clearRequest.onsuccess = () => {\n let count = 0;\n if (items.length === 0) return resolve();\n \n items.forEach(item => {\n const putRequest = store.put(item);\n putRequest.onerror = (e) => reject(e.target.error);\n putRequest.onsuccess = () => {\n count++;\n if (count === items.length) {\n resolve();\n }\n };\n });\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n clearAllIndexedDB() {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const collections = Object.keys(TABLE_MAP);\n const transaction = this.db.transaction(collections, 'readwrite');\n let count = 0;\n \n collections.forEach(col => {\n const store = transaction.objectStore(col);\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n clearRequest.onsuccess = () => {\n count++;\n if (count === collections.length) {\n resolve();\n }\n };\n });\n } catch (err) {\n reject(err);\n }\n });\n }\n\n // ── Local Folder Sync / File System Access Driver ──────────────────────────\n\n async verifyDirPermission(readWrite) {\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n this.folderSyncPermissionGranted = true;\n return true; // Capacitor native has permissions checked at app level\n }\n\n if (!this.dirHandle) return false;\n const opts = {};\n if (readWrite) opts.mode = 'readwrite';\n \n try {\n const currentPerm = await this.dirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.folderSyncPermissionGranted = true;\n return true;\n }\n \n // Request permission (must be called within a user gesture handler)\n const newPerm = await this.dirHandle.requestPermission(opts);\n this.folderSyncPermissionGranted = newPerm === 'granted';\n return this.folderSyncPermissionGranted;\n } catch (e) {\n this.folderSyncPermissionGranted = false;\n return false;\n }\n }\n\n async verifyBackupDirPermission(requestInteractive) {\n if (!this.backupDirHandle) return false;\n const opts = { mode: 'readwrite' };\n try {\n const currentPerm = await this.backupDirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.backupDirPermissionGranted = true;\n return true;\n }\n if (requestInteractive) {\n const newPerm = await this.backupDirHandle.requestPermission(opts);\n this.backupDirPermissionGranted = newPerm === 'granted';\n return this.backupDirPermissionGranted;\n }\n this.backupDirPermissionGranted = false;\n return false;\n } catch (e) {\n this.backupDirPermissionGranted = false;\n return false;\n }\n }\n\n // Ensure a subfolder for the current company exists within the selected directory.\n async ensureCompanyFolderHandle(customDirHandle) {\n const baseHandle = customDirHandle || this.dirHandle;\n if (!baseHandle) return null;\n const rawName = this.companySettings?.name || this.companySettings?.id || 'Company';\n const safeName = rawName.replace(/[\\\\/:*?\"<>|]/g, '_');\n try {\n const companyHandle = await baseHandle.getDirectoryHandle(safeName, { create: true });\n return companyHandle;\n } catch (e) {\n console.error('Failed to get/create company folder:', e);\n return null;\n }\n }\n\n async setBackupDirectory(dirHandle) {\n this.backupDirHandle = dirHandle || null;\n this.backupDirPermissionGranted = !!dirHandle;\n \n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n if (dirHandle) {\n const putReq = configStore.put({ key: 'backup_directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = () => resolve();\n } else {\n const deleteReq = configStore.delete('backup_directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n }\n });\n }\n }\n\n async backupToFolder(dirHandle) {\n const handle = dirHandle || this.backupDirHandle;\n if (!handle) throw new Error('No backup directory configured.');\n \n const currentPerm = await handle.queryPermission({ mode: 'readwrite' });\n if (currentPerm !== 'granted') {\n const newPerm = await handle.requestPermission({ mode: 'readwrite' });\n if (newPerm !== 'granted') {\n throw new Error('Write permission denied for backup directory.');\n }\n }\n \n // Ensure company-specific folder exists within the selected directory\n const companyFolderHandle = await this.ensureCompanyFolderHandle(dirHandle);\n const dataDir = await companyFolderHandle.getDirectoryHandle('data', { create: true });\n const collections = Object.keys(TABLE_MAP);\n for (const col of collections) {\n const items = this.cache[col] || [];\n const fileHandle = await dataDir.getFileHandle(`${col}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items, null, 2));\n await writable.close();\n }\n \n // Update last backup timestamp\n localStorage.setItem('relay_last_backup_time', new Date().toISOString());\n this.backupDirPermissionGranted = true;\n }\n\n async setLocalDirectory(dirHandle) {\n this.dirHandle = dirHandle;\n if (dirHandle) {\n this.folderSyncEnabled = true;\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('folder_sync_enabled'), 'true');\n }\n \n // Save handle to IndexedDB\n if (this.db) {\n await new Promise((resolve, reject) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const putReq = configStore.put({ key: 'directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = (e) => reject(e.target.error);\n });\n }\n \n this.folderSyncPermissionGranted = true;\n \n // Force rewrite all cached collections to directory immediately\n const collections = Object.keys(TABLE_MAP);\n await Promise.all(collections.map(col => this.writeCollectionToFolder(col, this.cache[col])));\n } else {\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n \n // Remove handle from IndexedDB\n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const deleteReq = configStore.delete('directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n });\n }\n }\n }\n\n async writeCollectionToFolder(collection, items) {\n if (!this.folderSyncEnabled) return;\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/data/${collection}.json`,\n data: JSON.stringify(items || [], null, 2),\n directory: 'DOCUMENTS',\n encoding: 'utf8',\n recursive: true\n });\n } catch (err) {\n console.error(`Capacitor failed to write collection ${collection}:`, err);\n }\n return;\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return;\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return;\n const dataDir = await companyFolder.getDirectoryHandle('data', { create: true });\n const fileHandle = await dataDir.getFileHandle(`${collection}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items || [], null, 2));\n await writable.close();\n } catch (err) {\n console.error(`Failed to write collection ${collection} to local directory:`, err);\n }\n }\n\n async acquireFolderLock(lockName, timeoutSeconds = 30) {\n if (!this.folderSyncEnabled || !this.dirHandle) return true; // Bypass if not using local folder sync\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return true;\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return true;\n const dataDir = await companyFolder.getDirectoryHandle('data', { create: true });\n \n const fileName = `${lockName}.lock`;\n let existingLock = null;\n try {\n const fileHandle = await dataDir.getFileHandle(fileName, { create: false });\n const file = await fileHandle.getFile();\n const text = await file.text();\n existingLock = JSON.parse(text);\n } catch (e) {\n // Not found, or unparseable\n }\n\n const now = Date.now();\n if (existingLock && existingLock.timestamp) {\n if (now - existingLock.timestamp < timeoutSeconds * 1000) {\n // Lock is still active\n return false;\n }\n }\n\n // Claim the lock\n const machineId = this.userId || Math.random().toString(36).substr(2, 9);\n const lockData = { machineId, timestamp: now };\n \n const writeHandle = await dataDir.getFileHandle(fileName, { create: true });\n const writable = await writeHandle.createWritable();\n await writable.write(JSON.stringify(lockData));\n await writable.close();\n\n // Wait 500ms and verify we still hold the lock (rudimentary collision detection)\n await new Promise(resolve => setTimeout(resolve, 500));\n const verifyHandle = await dataDir.getFileHandle(fileName, { create: false });\n const verifyFile = await verifyHandle.getFile();\n const verifyText = await verifyFile.text();\n const verifyData = JSON.parse(verifyText);\n\n if (verifyData.machineId !== machineId || verifyData.timestamp !== now) {\n // Someone else wrote over our lock!\n return false;\n }\n\n return true;\n } catch (err) {\n console.error(`Failed to acquire folder lock ${lockName}:`, err);\n return false; // Fail safe\n }\n }\n\n async releaseFolderLock(lockName) {\n if (!this.folderSyncEnabled || !this.dirHandle) return;\n try {\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return;\n const dataDir = await companyFolder.getDirectoryHandle('data', { create: true });\n await dataDir.removeEntry(`${lockName}.lock`);\n } catch (err) {\n // Ignore errors if file doesn't exist\n }\n }\n\n async writeDocumentFileToFolder(docId, name, dataUrl) {\n if (!this.folderSyncEnabled) return null;\n\n const ext = name.includes('.') ? name.split('.').pop() : 'bin';\n const fileName = `${docId}.${ext}`;\n const relativePath = `documents/${fileName}`;\n\n // Extract base64 payload\n const base64Parts = dataUrl.split(',');\n if (base64Parts.length < 2) return null;\n const base64Data = base64Parts[1];\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/documents/${fileName}`,\n data: base64Data,\n directory: 'DOCUMENTS',\n recursive: true\n });\n return relativePath;\n } catch (err) {\n console.error('Capacitor failed to write document:', err);\n return null;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return null;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return null;\n\n const mimeMatch = base64Parts[0].match(/:(.*?);/);\n const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';\n const binaryStr = atob(base64Data);\n const len = binaryStr.length;\n const bytes = new Uint8Array(len);\n for (let i = 0; i < len; i++) {\n bytes[i] = binaryStr.charCodeAt(i);\n }\n const blob = new Blob([bytes], { type: mime });\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return null;\n const docsDir = await companyFolder.getDirectoryHandle('documents', { create: true });\n const fileHandle = await docsDir.getFileHandle(fileName, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(blob);\n await writable.close();\n\n return relativePath;\n } catch (err) {\n console.error('Failed to save document file to local directory:', err);\n return null;\n }\n }\n\n async getDocumentUrl(doc) {\n if (!doc || !doc.url) return '';\n if (doc.url.startsWith('data:') || doc.url.startsWith('http')) {\n return doc.url;\n }\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n const result = await Filesystem.readFile({\n path: `RelayDispatchData/${doc.url}`,\n directory: 'DOCUMENTS'\n });\n const mime = doc.type || 'application/octet-stream';\n return `data:${mime};base64,${result.data}`;\n } catch (err) {\n console.error('Capacitor failed to read document file:', err);\n throw err;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) {\n throw new Error('Local directory sync is not configured.');\n }\n\n try {\n const hasPerm = await this.verifyDirPermission(false);\n if (!hasPerm) {\n throw new Error('Permission to access the local directory was not granted.');\n }\n\n const parts = doc.url.split('/');\n const dirName = parts[0];\n const fileName = parts[1];\n\n const dir = await this.dirHandle.getDirectoryHandle(dirName);\n const fileHandle = await dir.getFileHandle(fileName);\n const file = await fileHandle.getFile();\n return URL.createObjectURL(file);\n } catch (err) {\n console.error('Failed to read file from local directory:', err);\n throw err;\n }\n }\n\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // Initialize IndexedDB for cloud users too, to store backup directory handles\n await this.initIndexedDB();\n\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id\n query = query.eq('company_id', this.companyId);\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n await this.writeAllToIndexedDB(col, this.cache[col]);\n }\n });\n\n await Promise.all(promises);\n\n // Seed default user types if database is empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n // Unique channel name per subscribe — reusing a fixed name throws\n // \"cannot add postgres_changes callbacks ... after subscribe()\" when sync re-inits.\n const channel = supabase\n .channel(`table-db-changes-${this.companyId || 'anon'}-${Date.now()}`)\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n if (collection === 'contractors') {\n record.businessName = record.name;\n record.active = record.status === 'Active';\n }\n\n if (collection === 'suppliers') {\n record.active = record.status === 'Active';\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.project_id !== undefined) {\n record.projectId = record.project_id;\n delete record.project_id;\n }\n if (record.cost_center_id !== undefined) {\n record.costCenterId = record.cost_center_id;\n delete record.cost_center_id;\n }\n if (record.xero_sales_account_code !== undefined) {\n record.xeroSalesAccountCode = record.xero_sales_account_code;\n delete record.xero_sales_account_code;\n }\n if (record.xero_expense_account_code !== undefined) {\n record.xeroExpenseAccountCode = record.xero_expense_account_code;\n delete record.xero_expense_account_code;\n }\n if (record.xero_tracking_category_name !== undefined) {\n record.xeroTrackingCategoryName = record.xero_tracking_category_name;\n delete record.xero_tracking_category_name;\n }\n if (record.xero_tracking_option_name !== undefined) {\n record.xeroTrackingOptionName = record.xero_tracking_option_name;\n delete record.xero_tracking_option_name;\n }\n if (record.deactivated_at !== undefined) {\n record.deactivatedAt = record.deactivated_at;\n delete record.deactivated_at;\n }\n if (record.start_date !== undefined) {\n record.startDate = record.start_date;\n delete record.start_date;\n }\n if (record.end_date !== undefined) {\n record.endDate = record.end_date;\n delete record.end_date;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (collection === 'schedule') {\n if (record.job_id !== undefined) { record.jobId = record.job_id; delete record.job_id; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.technician_id !== undefined) { record.technicianId = record.technician_id; delete record.technician_id; }\n if (record.technician_name !== undefined) { record.technicianName = record.technician_name; delete record.technician_name; }\n if (record.start_hour !== undefined) { record.startHour = record.start_hour != null ? parseFloat(record.start_hour) : null; delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = record.end_hour != null ? parseFloat(record.end_hour) : null; delete record.end_hour; }\n if (record.day_offset !== undefined) { record.dayOffset = record.day_offset; delete record.day_offset; }\n if (record.customer_name !== undefined) { record.customerName = record.customer_name; delete record.customer_name; }\n if (record.site_address !== undefined) { record.siteAddress = record.site_address; delete record.site_address; }\n if (record.task_id !== undefined) { record.taskId = record.task_id; delete record.task_id; }\n if (record.task_name !== undefined) { record.taskName = record.task_name; delete record.task_name; }\n if (record.start_time !== undefined) { record.startTime = record.start_time; delete record.start_time; }\n if (record.finish_time !== undefined) { record.finishTime = record.finish_time; delete record.finish_time; }\n }\n\n if (record.line_items !== undefined) {\n if (collection === 'invoices' || collection === 'quotes') {\n const items = record.line_items;\n if (Array.isArray(items) && items.length > 0 && (items[0].lineItems !== undefined || items[0].items !== undefined || (items[0].name !== undefined && items[0].qty === undefined))) {\n record.sections = items;\n } else {\n record.lineItems = items;\n }\n } else {\n record.lineItems = record.line_items;\n }\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.paid_date !== undefined) {\n record.paidDate = record.paid_date;\n delete record.paid_date;\n }\n if (record.payment_method !== undefined) {\n record.paymentMethod = record.payment_method;\n delete record.payment_method;\n }\n if (record.stripe_session_id !== undefined) {\n record.stripeSessionId = record.stripe_session_id;\n delete record.stripe_session_id;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined || record.durationHours !== undefined || record.hours !== undefined) {\n const h = parseFloat(record.duration_hours !== undefined ? record.duration_hours : (record.durationHours !== undefined ? record.durationHours : record.hours)) || 0;\n record.durationHours = h;\n record.hours = h;\n delete record.duration_hours;\n }\n if (record.task_name !== undefined) { record.taskName = record.task_name; delete record.task_name; }\n if (record.task_id !== undefined) { record.taskId = record.task_id; delete record.task_id; }\n if (record.task_path !== undefined) { record.taskPath = record.task_path; delete record.task_path; }\n if (record.phase_name !== undefined) { record.phaseName = record.phase_name; delete record.phase_name; }\n if (record.phase_id !== undefined) { record.phaseId = record.phase_id; delete record.phase_id; }\n if (record.start_time !== undefined) { record.startTime = record.start_time; delete record.start_time; }\n if (record.finish_time !== undefined) { record.finishTime = record.finish_time; delete record.finish_time; }\n if (record.approved_by && typeof record.approved_by === 'string' && record.approved_by.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.approved_by.substring(9));\n if (parsed.taskId !== undefined) record.taskId = parsed.taskId;\n if (parsed.taskName !== undefined) record.taskName = parsed.taskName;\n if (parsed.taskPath !== undefined) record.taskPath = parsed.taskPath;\n if (parsed.phaseId !== undefined) record.phaseId = parsed.phaseId;\n if (parsed.phaseName !== undefined) record.phaseName = parsed.phaseName;\n if (parsed.startTime !== undefined) record.startTime = parsed.startTime;\n if (parsed.finishTime !== undefined) record.finishTime = parsed.finishTime;\n if (parsed.description !== undefined) record.description = parsed.description;\n if (parsed.jobNumber !== undefined) record.jobNumber = parsed.jobNumber;\n if (parsed.jobTitle !== undefined) record.jobTitle = parsed.jobTitle;\n if (parsed.hours !== undefined) {\n record.hours = parsed.hours;\n record.durationHours = parsed.hours;\n }\n record.approvedBy = parsed.approvedBy || null;\n } catch (e) {\n console.error(e);\n }\n delete record.approved_by;\n } else if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.license_number !== undefined) { record.licenseNumber = record.license_number; delete record.license_number; }\n if (record.hourly_rate !== undefined) { record.hourlyRate = parseFloat(record.hourly_rate); delete record.hourly_rate; }\n if (record.after_hours_rate !== undefined) { record.afterHoursRate = parseFloat(record.after_hours_rate); delete record.after_hours_rate; }\n if (record.callout_fee !== undefined) { record.calloutFee = parseFloat(record.callout_fee); delete record.callout_fee; }\n if (record.compliance_docs !== undefined) { record.complianceDocs = record.compliance_docs; delete record.compliance_docs; }\n if (record.account_number !== undefined) { record.accountNumber = record.account_number; delete record.account_number; }\n if (record.payment_terms !== undefined) { record.paymentTerms = record.payment_terms; delete record.payment_terms; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.start_location !== undefined) { record.startLocation = record.start_location; delete record.start_location; }\n if (record.day_offset !== undefined) { record.dayOffset = parseInt(record.day_offset); delete record.day_offset; }\n if (record.start_hour !== undefined) { record.startHour = parseFloat(record.start_hour); delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = parseFloat(record.end_hour); delete record.end_hour; }\n\n if (record.color && record.color.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.color.substring(9));\n record.taskId = parsed.taskId;\n record.taskPath = parsed.taskId;\n record.taskName = parsed.taskName;\n record.color = parsed.color;\n if (parsed.date !== undefined) record.date = parsed.date;\n if (parsed.startTime !== undefined) record.startTime = parsed.startTime;\n if (parsed.finishTime !== undefined) record.finishTime = parsed.finishTime;\n if (parsed.hours !== undefined) record.hours = parsed.hours;\n if (parsed.startHour !== undefined && parsed.startHour !== null) record.startHour = parsed.startHour;\n if (parsed.endHour !== undefined && parsed.endHour !== null) record.endHour = parsed.endHour;\n if (parsed.dayOffset !== undefined && parsed.dayOffset !== null) record.dayOffset = parsed.dayOffset;\n if (parsed.type !== undefined) record.type = parsed.type;\n if (parsed.status !== undefined) record.status = parsed.status;\n if (parsed.notes !== undefined) record.notes = parsed.notes;\n if (parsed.jobTitle !== undefined) record.jobTitle = parsed.jobTitle;\n } catch (e) {\n console.error(e);\n }\n }\n if (collection === 'invoices') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.invoiceType = 'Standard';\n record.laborProfileId = '';\n record.issueDate = '';\n record.originalQuoteId = '';\n record.originalQuoteNumber = '';\n record.originalSubtotal = 0;\n record.approvedVariationsSum = 0;\n record.pendingVariationsSum = 0;\n record.totalInternalCost = 0;\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.invoiceType = meta.invoiceType || 'Standard';\n record.laborProfileId = meta.laborProfileId || '';\n record.issueDate = meta.issueDate || '';\n record.originalQuoteId = meta.originalQuoteId || '';\n record.originalQuoteNumber = meta.originalQuoteNumber || '';\n record.originalSubtotal = meta.originalSubtotal || 0;\n record.approvedVariationsSum = meta.approvedVariationsSum || 0;\n record.pendingVariationsSum = meta.pendingVariationsSum || 0;\n record.totalInternalCost = meta.totalInternalCost || 0;\n record.jobIds = meta.jobIds || []; // consolidated invoices link multiple jobs\n }\n }\n if (collection === 'quotes') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.laborProfileId = '';\n record.description = '';\n record.isTemplate = false;\n record.templateId = '';\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.laborProfileId = meta.laborProfileId || '';\n record.description = meta.description || '';\n record.isTemplate = meta.isTemplate || false;\n record.templateId = meta.templateId || '';\n }\n }\n if (collection === 'jobs') {\n if (record.notes && record.notes.startsWith('__meta__:')) {\n try {\n const meta = JSON.parse(record.notes.substring(9));\n record.isRecurring = meta.isRecurring || false;\n record.recurringConfig = meta.recurringConfig || null;\n record.preferredTime = meta.preferredTime || '';\n record.description = meta.description || '';\n record.parentJobId = meta.parentJobId || null;\n record.assetName = meta.assetName || '';\n record.notes = meta.notes || '';\n } catch (e) {\n console.error('Error parsing jobs meta:', e);\n }\n }\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (collection === 'contractors') {\n record.name = record.businessName;\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (collection === 'suppliers') {\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.xeroSalesAccountCode !== undefined) {\n record.xero_sales_account_code = record.xeroSalesAccountCode;\n delete record.xeroSalesAccountCode;\n }\n if (record.xeroExpenseAccountCode !== undefined) {\n record.xero_expense_account_code = record.xeroExpenseAccountCode;\n delete record.xeroExpenseAccountCode;\n }\n if (record.xeroTrackingCategoryName !== undefined) {\n record.xero_tracking_category_name = record.xeroTrackingCategoryName;\n delete record.xeroTrackingCategoryName;\n }\n if (record.xeroTrackingOptionName !== undefined) {\n record.xero_tracking_option_name = record.xeroTrackingOptionName;\n delete record.xeroTrackingOptionName;\n }\n if (record.deactivatedAt !== undefined) {\n record.deactivated_at = record.deactivatedAt;\n delete record.deactivatedAt;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.projectId !== undefined) {\n record.project_id = record.projectId;\n delete record.projectId;\n }\n if (record.costCenterId !== undefined) {\n record.cost_center_id = record.costCenterId;\n delete record.costCenterId;\n }\n if (record.startDate !== undefined) {\n record.start_date = record.startDate;\n delete record.startDate;\n }\n if (record.endDate !== undefined) {\n record.end_date = record.endDate;\n delete record.endDate;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.sections !== undefined && (collection === 'invoices' || collection === 'quotes')) {\n record.line_items = record.sections;\n delete record.sections;\n } else if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.paidDate !== undefined) {\n record.paid_date = record.paidDate;\n delete record.paidDate;\n }\n if (record.paymentMethod !== undefined) {\n record.payment_method = record.paymentMethod;\n delete record.paymentMethod;\n }\n if (record.stripeSessionId !== undefined) {\n record.stripe_session_id = record.stripeSessionId;\n delete record.stripeSessionId;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined || record.hours !== undefined) {\n const h = parseFloat(record.durationHours !== undefined ? record.durationHours : record.hours) || 0;\n record.duration_hours = h;\n record.durationHours = h;\n record.hours = h;\n }\n if (record.taskName !== undefined) record.task_name = record.taskName;\n if (record.taskId !== undefined) record.task_id = record.taskId;\n if (record.taskPath !== undefined) record.task_path = record.taskPath;\n if (record.phaseName !== undefined) record.phase_name = record.phaseName;\n if (record.phaseId !== undefined) record.phase_id = record.phaseId;\n if (record.startTime !== undefined) record.start_time = record.startTime;\n if (record.finishTime !== undefined) record.finish_time = record.finishTime;\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.licenseNumber !== undefined) { record.license_number = record.licenseNumber; delete record.licenseNumber; }\n if (record.hourlyRate !== undefined) { record.hourly_rate = record.hourlyRate; delete record.hourlyRate; }\n if (record.afterHoursRate !== undefined) { record.after_hours_rate = record.afterHoursRate; delete record.afterHoursRate; }\n if (record.calloutFee !== undefined) { record.callout_fee = record.calloutFee; delete record.calloutFee; }\n if (record.complianceDocs !== undefined) { record.compliance_docs = record.complianceDocs; delete record.complianceDocs; }\n if (record.accountNumber !== undefined) { record.account_number = record.accountNumber; delete record.accountNumber; }\n if (record.paymentTerms !== undefined) { record.payment_terms = record.paymentTerms; delete record.paymentTerms; }\n if (record.jobNumber !== undefined) { record.job_number = record.jobNumber; delete record.jobNumber; }\n if (record.startLocation !== undefined) { record.start_location = record.startLocation; delete record.startLocation; }\n if (record.dayOffset !== undefined) { record.day_offset = record.dayOffset; delete record.dayOffset; }\n if (record.startHour !== undefined) { record.start_hour = record.startHour; delete record.startHour; }\n if (record.endHour !== undefined) { record.end_hour = record.endHour; delete record.endHour; }\n\n if (collection === 'schedule') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || null,\n color: record.color || null,\n date: record.date || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n hours: record.hours || null,\n startHour: record.startHour !== undefined ? record.startHour : null,\n endHour: record.endHour !== undefined ? record.endHour : null,\n dayOffset: record.dayOffset !== undefined ? record.dayOffset : null,\n type: record.type || null,\n notes: record.notes || null,\n jobTitle: record.jobTitle || record.title || null\n };\n record.color = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'jobs') {\n const meta = {\n isRecurring: record.isRecurring || false,\n recurringConfig: record.recurringConfig || null,\n preferredTime: record.preferredTime || '',\n description: record.description || '',\n parentJobId: record.parentJobId || null,\n assetName: record.assetName || '',\n notes: record.notes || ''\n };\n record.notes = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'invoices') {\n const meta = {\n sections: record.sections || [],\n invoiceType: record.invoiceType || 'Standard',\n laborProfileId: record.laborProfileId || '',\n issueDate: record.issueDate || '',\n originalQuoteId: record.originalQuoteId || '',\n originalQuoteNumber: record.originalQuoteNumber || '',\n originalSubtotal: record.originalSubtotal || 0,\n approvedVariationsSum: record.approvedVariationsSum || 0,\n pendingVariationsSum: record.pendingVariationsSum || 0,\n totalInternalCost: record.totalInternalCost || 0,\n jobIds: record.jobIds || [] // consolidated invoices link multiple jobs\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n if (collection === 'quotes') {\n const meta = {\n sections: record.sections || [],\n laborProfileId: record.laborProfileId || '',\n description: record.description || '',\n isTemplate: record.isTemplate || false,\n templateId: record.templateId || ''\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n if (collection === 'timesheets') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || record.phaseName || null,\n taskPath: record.taskPath || null,\n phaseId: record.phaseId || null,\n phaseName: record.phaseName || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n description: record.description || null,\n hours: record.hours !== undefined ? record.hours : record.duration_hours,\n approvedBy: record.approvedBy || null,\n jobNumber: record.jobNumber || null,\n jobTitle: record.jobTitle || null\n };\n record.approved_by = '__meta__:' + JSON.stringify(meta);\n delete record.approvedBy;\n }\n\n if (collection === 'schedule') {\n record.job_id = record.jobId || null; delete record.jobId;\n record.job_number = record.jobNumber || null; delete record.jobNumber;\n record.technician_id = record.technicianId || null; delete record.technicianId;\n record.technician_name = record.technicianName || null; delete record.technicianName;\n record.start_hour = record.startHour !== undefined ? record.startHour : null; delete record.startHour;\n record.end_hour = record.endHour !== undefined ? record.endHour : null; delete record.endHour;\n record.day_offset = record.dayOffset !== undefined ? record.dayOffset : null; delete record.dayOffset;\n record.customer_name = record.customerName || null; delete record.customerName;\n record.site_address = record.siteAddress || null; delete record.siteAddress;\n record.task_id = record.taskId || null; delete record.taskId;\n record.task_name = record.taskName || null; delete record.taskName;\n record.start_time = record.startTime || null; delete record.startTime;\n record.finish_time = record.finishTime || null; delete record.finishTime;\n }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n const items = this.cache[collection] || [];\n if (collection === 'technicians') {\n const loginMode = typeof localStorage !== 'undefined' ? localStorage.getItem('relay_login_mode') : null;\n if (loginMode === 'local') {\n const currentUserStr = typeof localStorage !== 'undefined' ? localStorage.getItem('currentUser') : null;\n if (currentUserStr) {\n const currentUser = JSON.parse(currentUserStr);\n if (currentUser && currentUser.id) {\n return [{\n id: currentUser.id,\n name: currentUser.name || 'Local Admin',\n role: 'Administrator',\n color: currentUser.color || '#FF5C00',\n userTypeId: currentUser.userTypeId,\n email: currentUser.email || '',\n username: currentUser.username || ''\n }];\n }\n }\n }\n }\n return items;\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Surface a failed background write to the user instead of failing silently.\n // (A silent insert/update failure leaves the optimistic cache entry in place, so the\n // record looks saved until the next reload — the classic \"it didn't save\" symptom.)\n _notifyWriteError(action, collection, error) {\n console.error(`Error trying to ${action} ${collection}:`, error?.message || error, error);\n try {\n import('../components/Notifications.js')\n .then(({ showToast }) => showToast(\n `Couldn't ${action} ${collection.replace(/s$/, '')} — ${error?.message || 'database error'}`,\n 'error'))\n .catch(() => {});\n } catch (e) {}\n }\n\n // All jobs an invoice bills: single jobId and/or consolidated jobIds\n _invoiceJobIds(inv) {\n return [inv?.jobId, ...(inv?.jobIds || [])].filter(Boolean);\n }\n\n // Invoice lifecycle → job status sync. Jobs are only marked \"Invoiced\" when their\n // invoice actually leaves Draft (Sent/Paid/etc.) — NOT when a draft is created, so\n // abandoning/deleting a draft never strands jobs in the wrong status.\n _markJobsInvoiced(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (job && job.status !== 'Invoiced') this.update('jobs', jid, { status: 'Invoiced' });\n });\n }\n\n // When an invoice is deleted, revert any of its jobs still marked \"Invoiced\" —\n // unless another invoice also bills that job.\n _revertJobsOnInvoiceDelete(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (!job || job.status !== 'Invoiced') return;\n const billedElsewhere = (this.cache.invoices || []).some(other =>\n other.id !== inv.id && this._invoiceJobIds(other).includes(jid));\n if (!billedElsewhere) this.update('jobs', jid, { status: 'Completed' });\n });\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = this.getNextNumber('LD-', 'leads');\n }\n if (collection === 'notifications' && !item.number) {\n item.number = this.getNextNumber('NT-', 'notifications');\n }\n if (collection === 'invoices' && !item.number) {\n item.number = this.getNextNumber('INV-', 'invoices');\n }\n if (collection === 'quotes' && !item.number) {\n item.number = this.getNextNumber('Q-', 'quotes');\n }\n if (collection === 'jobs' && !item.number) {\n item.number = this.getNextNumber('J-', 'jobs');\n }\n if (collection === 'purchaseOrders' && !item.number) {\n item.number = this.getNextNumber('PO-', 'purchaseOrders');\n }\n if (collection === 'projects' && !item.number) {\n item.number = this.getNextNumber('PRJ-', 'projects');\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Rare path: an invoice created already past Draft marks its jobs immediately\n if (collection === 'invoices' && item.status && item.status !== 'Draft' && item.status !== 'Void') {\n this._markJobsInvoiced(item);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Local Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (this.folderSyncEnabled) {\n if (collection === 'documents' && item.url && item.url.startsWith('data:')) {\n this.writeDocumentFileToFolder(item.id, item.name, item.url).then(relPath => {\n if (relPath) {\n item.url = relPath;\n // Re-save in IndexedDB and rewrite folder collection with updated path\n this.writeRecordToIndexedDB(collection, item);\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n });\n } else {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n }\n this.writeRecordToIndexedDB(collection, item).catch(err => {\n console.error(`Error saving ${collection} to IndexedDB:`, err);\n });\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n item.email = email;\n\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) {\n // Roll back the optimistic add so the UI matches the database\n this.cache[collection] = (this.cache[collection] || []).filter(x => x.id !== item.id);\n this.emit(collection, this.cache[collection]);\n this._notifyWriteError('save', collection, error);\n }\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n // Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n this.writeRecordToIndexedDB(collection, updated).catch(err => {\n console.error(`Error saving updated ${collection} to IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const isAuthUpdate = updates.username !== undefined ||\n updates.email !== undefined ||\n updates.password !== undefined ||\n updates.name !== undefined ||\n updates.role !== undefined ||\n updates.userTypeId !== undefined ||\n updates.color !== undefined ||\n updates.payRate !== undefined;\n\n if (isAuthUpdate) {\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to update user.');\n }\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n const dbPayload = this.denormalizeRecord(updated, 'technicians');\n const { error: dbError } = await supabase\n .from('profiles')\n .update(dbPayload)\n .eq('id', id);\n\n if (dbError) {\n throw dbError;\n }\n\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 2b. Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Roll back to the previous version so the UI matches the database\n const arr = [...(this.cache[collection] || [])];\n const i = arr.findIndex(x => x.id === id);\n if (i !== -1) { arr[i] = previous; this.cache[collection] = arr; this.emit(collection, arr); }\n this._notifyWriteError('update', collection, error);\n }\n });\n }\n\n return updated;\n }\n\n // Child records that must not outlive their parent. Deleting a parent cascades to\n // these (scheduling/alert artifacts). Financial/labor records (invoices, timesheets)\n // are deliberately KEPT — they're history and carry their own denormalised numbers.\n _cascadeDelete(collection, id) {\n const CASCADES = {\n jobs: [ ['schedule', 'jobId'], ['formInstances', 'jobId'], ['notifications', 'jobId'] ],\n assets: [ ['maintenancePlans', 'assetId'], ['notifications', 'assetId'] ],\n formTemplates: [ ['formInstances', 'templateId'] ],\n };\n (CASCADES[collection] || []).forEach(([childCol, fk]) => {\n (this.cache[childCol] || []).filter(c => c[fk] === id).forEach(c => this.delete(childCol, c.id));\n });\n }\n\n delete(collection, id) {\n // 0. Cascade-remove dependent child records BEFORE the parent leaves the cache\n this._cascadeDelete(collection, id);\n\n // 1. Update memory cache (keep a copy of the removed row for rollback)\n const removed = (this.cache[collection] || []).find(item => item.id === id);\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Deleting an invoice frees any jobs it (alone) had marked \"Invoiced\"\n if (collection === 'invoices' && removed) {\n this._revertJobsOnInvoiceDelete(removed);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n this.deleteRecordFromIndexedDB(collection, id).catch(err => {\n console.error(`Error deleting ${id} from ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Restore the row we optimistically removed\n if (removed) { this.cache[collection] = [...(this.cache[collection] || []), removed]; this.emit(collection, this.cache[collection]); }\n this._notifyWriteError('delete', collection, error);\n }\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n /**\n * Generate the next sequential number for a collection.\n * Scans existing records, finds the highest numeric suffix, and returns prefix + (max+1) zero-padded to 5 digits.\n * e.g. getNextNumber('INV-', 'invoices') → 'INV-00001', 'INV-00002', ...\n */\n getNextNumber(defaultPrefix, collection) {\n const settings = this.getSettings();\n const dt = settings.documentTheme || {};\n \n let prefix = defaultPrefix;\n let startingNum = 1;\n\n if (collection === 'invoices') {\n prefix = dt.invoicePrefix !== undefined ? dt.invoicePrefix : 'INV-';\n startingNum = dt.invoiceStartingNumber !== undefined ? parseInt(dt.invoiceStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n } else if (collection === 'quotes') {\n prefix = dt.quotePrefix !== undefined ? dt.quotePrefix : 'Q-';\n startingNum = dt.quoteStartingNumber !== undefined ? parseInt(dt.quoteStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n }\n\n const items = this.getAll(collection) || [];\n let maxNum = startingNum - 1;\n\n items.forEach(item => {\n if (item.number && typeof item.number === 'string' && item.number.startsWith(prefix)) {\n const numStr = item.number.slice(prefix.length);\n const num = parseInt(numStr, 10);\n if (!isNaN(num) && num > maxNum) {\n maxNum = num;\n }\n }\n });\n\n return prefix + (maxNum + 1).toString().padStart(5, '0');\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: defaultLogoLarge,\n logoSmall: defaultLogoSmall,\n markupPercent: 20,\n taxEnabled: true,\n taxRate: 10,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n jobTypes: ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'],\n supplierCategories: ['Electrical', 'Plumbing', 'HVAC', 'Fire Safety', 'Security', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#FF5C00',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n },\n ai: {\n enabled: (this.companyId && !this.companyId.startsWith('acct_')) ? !!(typeof import.meta.env !== 'undefined' && (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY)) : false,\n apiKey: (this.companyId && !this.companyId.startsWith('acct_')) ? (typeof import.meta.env !== 'undefined' ? (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY || '') : '') : '',\n endpoint: 'https://api.openai.com/v1/chat/completions',\n model: 'gpt-4o-mini',\n systemPrompt: 'You are Relay, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default logos if user has not uploaded custom ones\n if (!merged.logo || merged.logo.includes('logo-large.png')) merged.logo = defaultLogoLarge;\n if (!merged.logoSmall) merged.logoSmall = defaultLogoSmall;\n if (!merged.jobTypes) merged.jobTypes = [...defaultSettings.jobTypes];\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n merged.ai = { ...defaultSettings.ai, ...this.companySettings.ai };\n return merged;\n }\n\n return defaultSettings;\n }\n\n getTaxRate() {\n const settings = this.getSettings();\n if (settings.taxEnabled === false) return 0;\n const rate = settings.taxRate !== undefined ? Number(settings.taxRate) : 10;\n return rate / 100;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('settings'), JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── v1.3 Maps: per-user dispatch start location ────────────────────────────\n // Writes profiles.start_location directly (the technicians update() path goes\n // through the invite-user edge function, which doesn't carry this field).\n // Pass null to clear the override (falls back to the company office).\n async setStartLocation(startLocation) {\n const currentUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null') : null;\n if (!currentUser?.id) throw new Error('Not signed in');\n\n // Update cache first so getStartLocation() sees it immediately\n const techs = [...(this.cache.technicians || [])];\n const i = techs.findIndex(t => t.id === currentUser.id);\n if (i !== -1) {\n techs[i] = { ...techs[i], startLocation };\n this.cache.technicians = techs;\n this.emit('technicians', techs);\n }\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n // Local mode: persist with the rest of the technician record\n await this.writeRecordToIndexedDB?.('technicians', techs[i]).catch?.(() => {});\n return;\n }\n const { error } = await supabase\n .from('profiles')\n .update({ start_location: startLocation })\n .eq('id', currentUser.id);\n if (error) {\n this._notifyWriteError('update', 'start location', error);\n throw error;\n }\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId && !this.companyId.startsWith('acct_')) return true;\n return localStorage.getItem(this.getStorageKey('seeded')) === 'true';\n }\n\n save(collection, items) {\n const prev = this.cache[collection] || [];\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const p = this.writeAllToIndexedDB(collection, items).catch(err => {\n console.error(`Error saving all items to ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, items);\n }\n return p;\n }\n\n // Cloud mode: persist the whole collection. Upsert every current row, then delete\n // any rows that were removed from the array. (Previously this only touched the\n // in-memory cache, so every save() — assign-tech, role edits, form templates,\n // portal edits, stock receiving, mark-read, etc. — silently failed to persist.)\n const table = TABLE_MAP[collection];\n if (!table) return; // cache-only collections (e.g. 'activity') have no table\n\n const payload = items\n .filter(it => it && it.id)\n .map(it => this.denormalizeRecord({ ...it, companyId: this.companyId }, collection));\n if (payload.length) {\n supabase.from(table).upsert(payload).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n\n const currentIds = new Set(items.map(it => it && it.id));\n const removedIds = prev.filter(p => p && p.id && !currentIds.has(p.id)).map(p => p.id);\n if (removedIds.length) {\n supabase.from(table).delete().in('id', removedIds).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n }\n\n markSeeded() {\n localStorage.setItem(this.getStorageKey('seeded'), 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('formTemplates', templates);\n return;\n }\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n async seedDefaultUserTypes() {\n if (!this.companyId) return;\n\n const userTypes = [\n {\n id: `${this.companyId}_ut_admin`,\n name: 'Admin',\n description: 'Full system access',\n permissions: buildGranularPerms(() => true)\n },\n {\n id: `${this.companyId}_ut_manager`,\n name: 'Manager',\n description: 'Can manage most workflows but limited settings access',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company', 'manage_tax'].includes(key);\n return true;\n })\n },\n {\n id: `${this.companyId}_ut_tech`,\n name: 'Technician',\n description: 'Field staff — limited to their own jobs, schedule and timesheets',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n {\n id: `${this.companyId}_ut_office`,\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders', 'Suppliers'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n\n this.cache.userTypes = userTypes;\n this.emit('userTypes', userTypes);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('userTypes', userTypes);\n return;\n }\n\n const dbPayload = userTypes.map(ut => this.denormalizeRecord({ ...ut, companyId: this.companyId }, 'userTypes'));\n const { error } = await supabase.from('user_types').insert(dbPayload);\n if (error) console.error('Error seeding default user types:', error);\n }\n\n async seedDefaultTechnicians() {\n const companyId = this.companyId;\n if (!companyId) return;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const managerTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_manager` : 'ut_manager';\n const techTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_tech` : 'ut_tech';\n const officeTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_office` : 'ut_office';\n\n const defaultTechs = [\n { id: `${companyId}_tech_1`, name: 'Jake Morrow', role: 'Senior Electrician', color: '#3B82F6', userTypeId: adminTypeId, payRate: 95.00, email: 'jake@apexpowerservices.com.au', phone: '0412 233 445', username: 'jake', password: '123456' },\n { id: `${companyId}_tech_2`, name: 'Ryan Holt', role: 'Service Manager', color: '#10B981', userTypeId: managerTypeId, payRate: 85.00, email: 'ryan@apexpowerservices.com.au', phone: '0423 344 556', username: 'ryan', password: '123456' },\n { id: `${companyId}_tech_3`, name: 'Sandra Okafor', role: 'Electrician', color: '#8B5CF6', userTypeId: techTypeId, payRate: 80.00, email: 'sandra@apexpowerservices.com.au', phone: '0434 455 667', username: 'sandra', password: '123456' },\n { id: `${companyId}_tech_4`, name: 'Dean Caruso', role: 'Office Administrator',color: '#F59E0B', userTypeId: officeTypeId, payRate: 50.00, email: 'dean@apexpowerservices.com.au', phone: '0445 566 778', username: 'dean', password: '123456' }\n ];\n\n this.cache.technicians = defaultTechs;\n this.emit('technicians', defaultTechs);\n\n if (companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('technicians', defaultTechs);\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder('technicians', defaultTechs).catch(err => {\n console.error('Error writing seeded technicians to local folder:', err);\n });\n }\n return;\n }\n\n const dbPayload = defaultTechs.map(t => this.denormalizeRecord({ ...t, companyId }, 'technicians'));\n const { error } = await supabase.from('technicians').insert(dbPayload);\n if (error) console.error('Error seeding default technicians:', error);\n }\n\n async migrateLocalToCloud(companyId, adminUserId) {\n this.companyId = companyId;\n this.userId = adminUserId;\n\n // 1. Update the remote company settings with local companySettings\n const currentSettings = this.getSettings();\n const { error: compErr } = await supabase.from('companies').update({\n abn: currentSettings.abn || '',\n phone: currentSettings.phone || '',\n domain: currentSettings.domain || '',\n email: currentSettings.email || '',\n address: currentSettings.address || '',\n settings: currentSettings\n }).eq('id', companyId);\n if (compErr) console.error('Error updating cloud company settings during migration:', compErr);\n\n // 2. Loop through all collections and push to Supabase\n // Skip 'companies' and 'technicians'\n const collectionsToMigrate = Object.keys(TABLE_MAP).filter(col => col !== 'companies' && col !== 'technicians');\n\n for (const col of collectionsToMigrate) {\n const items = this.cache[col] || [];\n if (items.length === 0) continue;\n\n const table = TABLE_MAP[col];\n if (!table) continue;\n\n // Assign the new cloud company_id and denormalize\n const payload = items.map(item => {\n const itemCopy = { ...item, companyId };\n return this.denormalizeRecord(itemCopy, col);\n });\n\n // Write to Supabase using upsert\n const { error } = await supabase.from(table).upsert(payload);\n if (error) {\n console.error(`Error migrating collection ${col}:`, error);\n throw new Error(`Failed to migrate ${col} data: ${error.message}`);\n }\n }\n\n // 3. Clear/close local DB connection and sync from Cloud\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n }\n\n deleteLocalAccountData(accountId) {\n return new Promise((resolve) => {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n if (typeof localStorage !== 'undefined') {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem(`relay_${accountId}_${col}`);\n });\n localStorage.removeItem(`relay_${accountId}_folder_sync_enabled`);\n localStorage.removeItem(`relay_${accountId}_seeded`);\n localStorage.removeItem(`relay_${accountId}_settings`);\n }\n if (typeof window !== 'undefined' && window.indexedDB) {\n const req = window.indexedDB.deleteDatabase(`RelayDispatchDB_${accountId}`);\n req.onsuccess = () => resolve();\n req.onerror = () => resolve();\n req.onblocked = () => {\n console.warn('Database deletion blocked, resolving anyway');\n resolve();\n };\n } else {\n resolve();\n }\n });\n }\n\n async clearAll() {\n this.clearSync();\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n \n if (activeAccount) {\n await this.deleteLocalAccountData(activeAccount);\n } else {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n this.dirHandle = null;\n try {\n await this.clearAllIndexedDB();\n } catch (err) {\n console.error('Error clearing IndexedDB:', err);\n }\n }\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "1e11ec02c0e5d4f8702b000e47ed0b6a2cc9bbb0": {"t": 1785754170.8901494, "n": 117243, "h": "be357e15dc25d7b8", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\nconst defaultLogoLarge = new URL('../assets/RELAY_Dispatch_Logo.png', import.meta.url).href;\nconst defaultLogoSmall = new URL('../assets/logo-small.png', import.meta.url).href;\n\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n passwordResetRequests: 'password_reset_requests',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents',\n leads: 'leads',\n schedule: 'schedule',\n projects: 'projects',\n costCenters: 'cost_centers',\n deputyAsks: null\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"deactivated\",\n \"deactivated_at\",\n \"start_location\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"project_id\",\n \"cost_center_id\",\n \"created_at\",\n \"updated_at\"\n ],\n projects: [\n \"id\",\n \"company_id\",\n \"number\",\n \"name\",\n \"customer_id\",\n \"customer_name\",\n \"site_address\",\n \"status\",\n \"description\",\n \"start_date\",\n \"end_date\",\n \"created_at\",\n \"updated_at\"\n ],\n costCenters: [\n \"id\",\n \"company_id\",\n \"name\",\n \"code\",\n \"active\",\n \"xero_sales_account_code\",\n \"xero_expense_account_code\",\n \"xero_tracking_category_name\",\n \"xero_tracking_option_name\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"paid_date\",\n \"payment_method\",\n \"stripe_session_id\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"license_number\",\n \"hourly_rate\",\n \"after_hours_rate\",\n \"callout_fee\",\n \"specialties\",\n \"notes\",\n \"portal_token\",\n \"compliance_docs\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"address\",\n \"category\",\n \"account_number\",\n \"payment_terms\",\n \"notes\",\n \"attachments\",\n \"created_at\",\n \"updated_at\"\n ],\n leads: [\n \"id\",\n \"company_id\",\n \"number\",\n \"title\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"status\",\n \"source\",\n \"value\",\n \"description\",\n \"priority\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"created_at\",\n \"updated_at\"\n ],\n password_reset_requests: [\n \"id\",\n \"technician_id\",\n \"employee_id\",\n \"requested_at\",\n \"status\",\n \"token\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"type\",\n \"description\",\n \"priority\",\n \"read\",\n \"number\",\n \"asset_id\",\n \"job_id\",\n \"due_date\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"type\",\n \"date\",\n \"status\",\n \"notes\",\n \"start_time\",\n \"finish_time\",\n \"hours\",\n \"task_id\",\n \"task_name\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g><text x=\"90\" y=\"36\" fill=\"#2D3134\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"25\" font-weight=\"900\" letter-spacing=\"0.04em\">DISPATCH</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nconst MODULE_PERMS = {\n 'Dashboard': [{ key: 'view' }],\n 'Customers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_contacts' }],\n 'Leads': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'convert' }],\n 'Quotes': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'approve' }, { key: 'convert' }, { key: 'generate_pdf' }],\n 'Jobs': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_tasks' }, { key: 'book_time' }, { key: 'view_costs' }, { key: 'view_quotes_tab' }, { key: 'view_pos_tab' }, { key: 'view_timesheets_tab' }, { key: 'view_invoices_tab' }, { key: 'manage_materials' }, { key: 'create_invoice' }],\n 'Timesheets': [{ key: 'view_own' }, { key: 'view' }, { key: 'create' }, { key: 'approve' }, { key: 'edit_all' }, { key: 'export' }],\n 'Assets': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Schedule': [{ key: 'view_own' }, { key: 'view' }, { key: 'edit' }],\n 'Contractors': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }],\n 'Suppliers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Stock': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Purchase Orders': [{ key: 'view' }, { key: 'create' }, { key: 'approve' }],\n 'Invoices': [{ key: 'view' }, { key: 'create' }, { key: 'send' }, { key: 'void' }],\n 'Reports': [{ key: 'view' }, { key: 'export' }],\n 'Documents': [{ key: 'view' }, { key: 'upload' }],\n 'Settings': [{ key: 'view' }, { key: 'edit_company' }, { key: 'manage_users' }, { key: 'manage_tax' }]\n};\n\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n this.db = null;\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n this.backupDirHandle = null;\n this.backupDirPermissionGranted = false;\n\n // Check if we have a cloud user at boot (before filling cache)\n const bootUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n const isCloudUser = !!(bootUser && bootUser.companyId && !bootUser.companyId.startsWith('acct_'));\n\n if (!isCloudUser && bootUser && bootUser.companyId && typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', bootUser.companyId);\n }\n\n // Pre-initialize empty arrays for collections.\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Load local settings if they exist (only for offline/demo mode)\n if (!isCloudUser && typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId && !this.companyId.startsWith('acct_')) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n // Only clear sync if we were previously in cloud sync mode or companyId is not defined\n if (!this.companyId || !this.companyId.startsWith('acct_')) {\n this.clearSync();\n }\n }\n });\n\n // Auto-trigger sync or local load if user already logged in at boot\n if (isCloudUser) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n this.initPromise = this.initializeCloudSync();\n } else {\n if (bootUser && bootUser.companyId) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n }\n this.initPromise = this.initializeLocalStore();\n }\n }\n\n getStorageKey(collection) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (activeAccount) {\n return `relay_${activeAccount}_${collection}`;\n }\n return `simpro_${collection}`;\n }\n\n getDBName() {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n return activeAccount ? `RelayDispatchDB_${activeAccount}` : 'RelayDispatchDB';\n }\n\n async initializeUser(user) {\n if (user && user.companyId && !user.companyId.startsWith('acct_')) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n } else {\n this.clearSync();\n if (user && user.companyId) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', user.companyId);\n }\n } else {\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n }\n this.initPromise = this.initializeLocalStore();\n await this.initPromise;\n }\n }\n\n async initializeLocalStore() {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n\n try {\n await this.initIndexedDB();\n } catch (e) {\n console.error('Failed to initialize IndexedDB, falling back to empty arrays:', e);\n }\n\n // Load local settings if they exist (namespaced)\n this.companySettings = null;\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n this.emit('settings', this.getSettings());\n\n // Seed default user types and forms if empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n if (this.cache.technicians.length === 0) {\n await this.seedDefaultTechnicians();\n }\n }\n\n initIndexedDB() {\n return new Promise((resolve, reject) => {\n if (typeof window === 'undefined' || !window.indexedDB) {\n return resolve();\n }\n\n const dbName = this.getDBName();\n const request = window.indexedDB.open(dbName, 4);\n\n request.onerror = (e) => {\n console.error('IndexedDB open error:', e.target.error);\n reject(e.target.error);\n };\n\n request.onsuccess = async (e) => {\n this.db = e.target.result;\n \n // 1. Load custom directory sync config from IndexedDB config store if it exists\n try {\n if (this.db.objectStoreNames.contains('config')) {\n await new Promise((resConfig) => {\n const transaction = this.db.transaction('config', 'readonly');\n const configStore = transaction.objectStore('config');\n \n const getSync = new Promise((resolve) => {\n const req = configStore.get('directory_handle');\n req.onsuccess = () => {\n console.log('[DEBUG Store] getSync onsuccess result:', req.result);\n if (req.result) {\n this.dirHandle = req.result.value;\n console.log('[DEBUG Store] loaded dirHandle:', this.dirHandle ? this.dirHandle.name : null);\n }\n resolve();\n };\n req.onerror = (e) => {\n console.error('[DEBUG Store] getSync onerror:', e);\n resolve();\n };\n });\n\n const getBackup = new Promise((resolve) => {\n const req = configStore.get('backup_directory_handle');\n req.onsuccess = () => {\n if (req.result) this.backupDirHandle = req.result.value;\n resolve();\n };\n req.onerror = () => resolve();\n });\n\n Promise.all([getSync, getBackup]).then(() => resConfig());\n });\n }\n } catch (err) {\n console.warn('Failed to read config from IndexedDB:', err);\n }\n\n // Verify directory permission silently on boot if handle exists\n if (this.dirHandle) {\n try {\n // Check permission silently without prompt dialog\n const opts = { mode: 'readwrite' };\n const status = await this.dirHandle.queryPermission(opts);\n this.folderSyncPermissionGranted = status === 'granted';\n } catch (err) {\n console.warn('Failed to query directory permission on boot:', err);\n }\n }\n\n // Verify backup directory permission silently on boot if handle exists\n if (this.backupDirHandle) {\n try {\n const opts = { mode: 'readwrite' };\n const status = await this.backupDirHandle.queryPermission(opts);\n this.backupDirPermissionGranted = status === 'granted';\n\n // Auto-trigger backup if granted and > 7 days since last backup\n if (this.backupDirPermissionGranted) {\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;\n if (!lastBackup || new Date(lastBackup).getTime() < sevenDaysAgo) {\n this.backupToFolder().catch(err => {\n console.error('Auto background backup failed:', err);\n });\n }\n }\n } catch (err) {\n console.warn('Failed to query backup directory permission on boot:', err);\n }\n }\n\n // Load all collections into cache\n const collections = Object.keys(TABLE_MAP);\n const loadPromises = collections.map(async (col) => {\n try {\n this.cache[col] = await this.readAllFromIndexedDB(col);\n } catch (err) {\n console.error(`Error loading collection ${col} from IndexedDB:`, err);\n this.cache[col] = [];\n }\n });\n\n await Promise.all(loadPromises);\n\n // Perform migration if we have old data in localStorage (only if not in a namespaced local account profile)\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (!activeAccount) {\n const migratePromises = collections.map(async (col) => {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n const items = JSON.parse(localData);\n if (Array.isArray(items) && items.length > 0) {\n // Write all items to IndexedDB\n await this.writeAllToIndexedDB(col, items);\n // Merge into cache if cache is empty\n if (this.cache[col].length === 0) {\n this.cache[col] = items;\n }\n }\n localStorage.removeItem('simpro_' + col);\n } catch (err) {\n console.error(`Migration error for ${col}:`, err);\n }\n }\n });\n\n await Promise.all(migratePromises);\n }\n resolve();\n };\n\n request.onupgradeneeded = (e) => {\n const db = e.target.result;\n const collections = Object.keys(TABLE_MAP);\n collections.forEach(col => {\n if (!db.objectStoreNames.contains(col)) {\n db.createObjectStore(col, { keyPath: 'id' });\n }\n });\n if (!db.objectStoreNames.contains('config')) {\n db.createObjectStore('config', { keyPath: 'key' });\n }\n };\n });\n }\n\n readAllFromIndexedDB(storeName) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve([]);\n try {\n const transaction = this.db.transaction(storeName, 'readonly');\n const store = transaction.objectStore(storeName);\n const getAllRequest = store.getAll();\n\n getAllRequest.onsuccess = () => {\n resolve(getAllRequest.result || []);\n };\n\n getAllRequest.onerror = (e) => {\n reject(e.target.error);\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeRecordToIndexedDB(storeName, record) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const putRequest = store.put(record);\n\n putRequest.onsuccess = () => resolve();\n putRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n deleteRecordFromIndexedDB(storeName, id) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const deleteRequest = store.delete(id);\n\n deleteRequest.onsuccess = () => resolve();\n deleteRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeAllToIndexedDB(storeName, items) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n \n // Clear existing\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n \n clearRequest.onsuccess = () => {\n let count = 0;\n if (items.length === 0) return resolve();\n \n items.forEach(item => {\n const putRequest = store.put(item);\n putRequest.onerror = (e) => reject(e.target.error);\n putRequest.onsuccess = () => {\n count++;\n if (count === items.length) {\n resolve();\n }\n };\n });\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n clearAllIndexedDB() {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const collections = Object.keys(TABLE_MAP);\n const transaction = this.db.transaction(collections, 'readwrite');\n let count = 0;\n \n collections.forEach(col => {\n const store = transaction.objectStore(col);\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n clearRequest.onsuccess = () => {\n count++;\n if (count === collections.length) {\n resolve();\n }\n };\n });\n } catch (err) {\n reject(err);\n }\n });\n }\n\n // ── Local Folder Sync / File System Access Driver ──────────────────────────\n\n async verifyDirPermission(readWrite) {\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n this.folderSyncPermissionGranted = true;\n return true; // Capacitor native has permissions checked at app level\n }\n\n if (!this.dirHandle) return false;\n const opts = {};\n if (readWrite) opts.mode = 'readwrite';\n \n try {\n const currentPerm = await this.dirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.folderSyncPermissionGranted = true;\n return true;\n }\n \n // Request permission (must be called within a user gesture handler)\n const newPerm = await this.dirHandle.requestPermission(opts);\n this.folderSyncPermissionGranted = newPerm === 'granted';\n return this.folderSyncPermissionGranted;\n } catch (e) {\n this.folderSyncPermissionGranted = false;\n return false;\n }\n }\n\n async verifyBackupDirPermission(requestInteractive) {\n if (!this.backupDirHandle) return false;\n const opts = { mode: 'readwrite' };\n try {\n const currentPerm = await this.backupDirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.backupDirPermissionGranted = true;\n return true;\n }\n if (requestInteractive) {\n const newPerm = await this.backupDirHandle.requestPermission(opts);\n this.backupDirPermissionGranted = newPerm === 'granted';\n return this.backupDirPermissionGranted;\n }\n this.backupDirPermissionGranted = false;\n return false;\n } catch (e) {\n this.backupDirPermissionGranted = false;\n return false;\n }\n }\n\n // Ensure a subfolder for the current company exists within the selected directory.\n async ensureCompanyFolderHandle(customDirHandle) {\n const baseHandle = customDirHandle || this.dirHandle;\n if (!baseHandle) return null;\n const rawName = this.companySettings?.name || this.companySettings?.id || 'Company';\n const safeName = rawName.replace(/[\\\\/:*?\"<>|]/g, '_');\n try {\n const companyHandle = await baseHandle.getDirectoryHandle(safeName, { create: true });\n return companyHandle;\n } catch (e) {\n console.error('Failed to get/create company folder:', e);\n return null;\n }\n }\n\n async setBackupDirectory(dirHandle) {\n this.backupDirHandle = dirHandle || null;\n this.backupDirPermissionGranted = !!dirHandle;\n \n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n if (dirHandle) {\n const putReq = configStore.put({ key: 'backup_directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = () => resolve();\n } else {\n const deleteReq = configStore.delete('backup_directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n }\n });\n }\n }\n\n async backupToFolder(dirHandle) {\n const handle = dirHandle || this.backupDirHandle;\n if (!handle) throw new Error('No backup directory configured.');\n \n const currentPerm = await handle.queryPermission({ mode: 'readwrite' });\n if (currentPerm !== 'granted') {\n const newPerm = await handle.requestPermission({ mode: 'readwrite' });\n if (newPerm !== 'granted') {\n throw new Error('Write permission denied for backup directory.');\n }\n }\n \n // Ensure company-specific folder exists within the selected directory\n const companyFolderHandle = await this.ensureCompanyFolderHandle(dirHandle);\n const dataDir = await companyFolderHandle.getDirectoryHandle('data', { create: true });\n const collections = Object.keys(TABLE_MAP);\n for (const col of collections) {\n const items = this.cache[col] || [];\n const fileHandle = await dataDir.getFileHandle(`${col}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items, null, 2));\n await writable.close();\n }\n \n // Update last backup timestamp\n localStorage.setItem('relay_last_backup_time', new Date().toISOString());\n this.backupDirPermissionGranted = true;\n }\n\n async setLocalDirectory(dirHandle) {\n this.dirHandle = dirHandle;\n if (dirHandle) {\n this.folderSyncEnabled = true;\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('folder_sync_enabled'), 'true');\n }\n \n // Save handle to IndexedDB\n if (this.db) {\n await new Promise((resolve, reject) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const putReq = configStore.put({ key: 'directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = (e) => reject(e.target.error);\n });\n }\n \n this.folderSyncPermissionGranted = true;\n \n // Force rewrite all cached collections to directory immediately\n const collections = Object.keys(TABLE_MAP);\n await Promise.all(collections.map(col => this.writeCollectionToFolder(col, this.cache[col])));\n } else {\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n \n // Remove handle from IndexedDB\n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const deleteReq = configStore.delete('directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n });\n }\n }\n }\n\n async writeCollectionToFolder(collection, items) {\n if (!this.folderSyncEnabled) return;\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/data/${collection}.json`,\n data: JSON.stringify(items || [], null, 2),\n directory: 'DOCUMENTS',\n encoding: 'utf8',\n recursive: true\n });\n } catch (err) {\n console.error(`Capacitor failed to write collection ${collection}:`, err);\n }\n return;\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return;\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return;\n const dataDir = await companyFolder.getDirectoryHandle('data', { create: true });\n const fileHandle = await dataDir.getFileHandle(`${collection}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items || [], null, 2));\n await writable.close();\n } catch (err) {\n console.error(`Failed to write collection ${collection} to local directory:`, err);\n }\n }\n\n async writeDocumentFileToFolder(docId, name, dataUrl) {\n if (!this.folderSyncEnabled) return null;\n\n const ext = name.includes('.') ? name.split('.').pop() : 'bin';\n const fileName = `${docId}.${ext}`;\n const relativePath = `documents/${fileName}`;\n\n // Extract base64 payload\n const base64Parts = dataUrl.split(',');\n if (base64Parts.length < 2) return null;\n const base64Data = base64Parts[1];\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/documents/${fileName}`,\n data: base64Data,\n directory: 'DOCUMENTS',\n recursive: true\n });\n return relativePath;\n } catch (err) {\n console.error('Capacitor failed to write document:', err);\n return null;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return null;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return null;\n\n const mimeMatch = base64Parts[0].match(/:(.*?);/);\n const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';\n const binaryStr = atob(base64Data);\n const len = binaryStr.length;\n const bytes = new Uint8Array(len);\n for (let i = 0; i < len; i++) {\n bytes[i] = binaryStr.charCodeAt(i);\n }\n const blob = new Blob([bytes], { type: mime });\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return null;\n const docsDir = await companyFolder.getDirectoryHandle('documents', { create: true });\n const fileHandle = await docsDir.getFileHandle(fileName, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(blob);\n await writable.close();\n\n return relativePath;\n } catch (err) {\n console.error('Failed to save document file to local directory:', err);\n return null;\n }\n }\n\n async getDocumentUrl(doc) {\n if (!doc || !doc.url) return '';\n if (doc.url.startsWith('data:') || doc.url.startsWith('http')) {\n return doc.url;\n }\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n const result = await Filesystem.readFile({\n path: `RelayDispatchData/${doc.url}`,\n directory: 'DOCUMENTS'\n });\n const mime = doc.type || 'application/octet-stream';\n return `data:${mime};base64,${result.data}`;\n } catch (err) {\n console.error('Capacitor failed to read document file:', err);\n throw err;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) {\n throw new Error('Local directory sync is not configured.');\n }\n\n try {\n const hasPerm = await this.verifyDirPermission(false);\n if (!hasPerm) {\n throw new Error('Permission to access the local directory was not granted.');\n }\n\n const parts = doc.url.split('/');\n const dirName = parts[0];\n const fileName = parts[1];\n\n const dir = await this.dirHandle.getDirectoryHandle(dirName);\n const fileHandle = await dir.getFileHandle(fileName);\n const file = await fileHandle.getFile();\n return URL.createObjectURL(file);\n } catch (err) {\n console.error('Failed to read file from local directory:', err);\n throw err;\n }\n }\n\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // Initialize IndexedDB for cloud users too, to store backup directory handles\n await this.initIndexedDB();\n\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id\n query = query.eq('company_id', this.companyId);\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n await this.writeAllToIndexedDB(col, this.cache[col]);\n }\n });\n\n await Promise.all(promises);\n\n // Seed default user types if database is empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n // Unique channel name per subscribe — reusing a fixed name throws\n // \"cannot add postgres_changes callbacks ... after subscribe()\" when sync re-inits.\n const channel = supabase\n .channel(`table-db-changes-${this.companyId || 'anon'}-${Date.now()}`)\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n if (collection === 'contractors') {\n record.businessName = record.name;\n record.active = record.status === 'Active';\n }\n\n if (collection === 'suppliers') {\n record.active = record.status === 'Active';\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.project_id !== undefined) {\n record.projectId = record.project_id;\n delete record.project_id;\n }\n if (record.cost_center_id !== undefined) {\n record.costCenterId = record.cost_center_id;\n delete record.cost_center_id;\n }\n if (record.xero_sales_account_code !== undefined) {\n record.xeroSalesAccountCode = record.xero_sales_account_code;\n delete record.xero_sales_account_code;\n }\n if (record.xero_expense_account_code !== undefined) {\n record.xeroExpenseAccountCode = record.xero_expense_account_code;\n delete record.xero_expense_account_code;\n }\n if (record.xero_tracking_category_name !== undefined) {\n record.xeroTrackingCategoryName = record.xero_tracking_category_name;\n delete record.xero_tracking_category_name;\n }\n if (record.xero_tracking_option_name !== undefined) {\n record.xeroTrackingOptionName = record.xero_tracking_option_name;\n delete record.xero_tracking_option_name;\n }\n if (record.deactivated_at !== undefined) {\n record.deactivatedAt = record.deactivated_at;\n delete record.deactivated_at;\n }\n if (record.start_date !== undefined) {\n record.startDate = record.start_date;\n delete record.start_date;\n }\n if (record.end_date !== undefined) {\n record.endDate = record.end_date;\n delete record.end_date;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (collection === 'schedule') {\n if (record.job_id !== undefined) { record.jobId = record.job_id; delete record.job_id; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.technician_id !== undefined) { record.technicianId = record.technician_id; delete record.technician_id; }\n if (record.technician_name !== undefined) { record.technicianName = record.technician_name; delete record.technician_name; }\n if (record.start_hour !== undefined) { record.startHour = record.start_hour != null ? parseFloat(record.start_hour) : null; delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = record.end_hour != null ? parseFloat(record.end_hour) : null; delete record.end_hour; }\n if (record.day_offset !== undefined) { record.dayOffset = record.day_offset; delete record.day_offset; }\n if (record.customer_name !== undefined) { record.customerName = record.customer_name; delete record.customer_name; }\n if (record.site_address !== undefined) { record.siteAddress = record.site_address; delete record.site_address; }\n if (record.task_id !== undefined) { record.taskId = record.task_id; delete record.task_id; }\n if (record.task_name !== undefined) { record.taskName = record.task_name; delete record.task_name; }\n if (record.start_time !== undefined) { record.startTime = record.start_time; delete record.start_time; }\n if (record.finish_time !== undefined) { record.finishTime = record.finish_time; delete record.finish_time; }\n }\n\n if (record.line_items !== undefined) {\n if (collection === 'invoices' || collection === 'quotes') {\n const items = record.line_items;\n if (Array.isArray(items) && items.length > 0 && (items[0].lineItems !== undefined || items[0].items !== undefined || (items[0].name !== undefined && items[0].qty === undefined))) {\n record.sections = items;\n } else {\n record.lineItems = items;\n }\n } else {\n record.lineItems = record.line_items;\n }\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.paid_date !== undefined) {\n record.paidDate = record.paid_date;\n delete record.paid_date;\n }\n if (record.payment_method !== undefined) {\n record.paymentMethod = record.payment_method;\n delete record.payment_method;\n }\n if (record.stripe_session_id !== undefined) {\n record.stripeSessionId = record.stripe_session_id;\n delete record.stripe_session_id;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined || record.durationHours !== undefined || record.hours !== undefined) {\n const h = parseFloat(record.duration_hours !== undefined ? record.duration_hours : (record.durationHours !== undefined ? record.durationHours : record.hours)) || 0;\n record.durationHours = h;\n record.hours = h;\n delete record.duration_hours;\n }\n if (record.task_name !== undefined) { record.taskName = record.task_name; delete record.task_name; }\n if (record.task_id !== undefined) { record.taskId = record.task_id; delete record.task_id; }\n if (record.task_path !== undefined) { record.taskPath = record.task_path; delete record.task_path; }\n if (record.phase_name !== undefined) { record.phaseName = record.phase_name; delete record.phase_name; }\n if (record.phase_id !== undefined) { record.phaseId = record.phase_id; delete record.phase_id; }\n if (record.start_time !== undefined) { record.startTime = record.start_time; delete record.start_time; }\n if (record.finish_time !== undefined) { record.finishTime = record.finish_time; delete record.finish_time; }\n if (record.approved_by && typeof record.approved_by === 'string' && record.approved_by.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.approved_by.substring(9));\n if (parsed.taskId !== undefined) record.taskId = parsed.taskId;\n if (parsed.taskName !== undefined) record.taskName = parsed.taskName;\n if (parsed.taskPath !== undefined) record.taskPath = parsed.taskPath;\n if (parsed.phaseId !== undefined) record.phaseId = parsed.phaseId;\n if (parsed.phaseName !== undefined) record.phaseName = parsed.phaseName;\n if (parsed.startTime !== undefined) record.startTime = parsed.startTime;\n if (parsed.finishTime !== undefined) record.finishTime = parsed.finishTime;\n if (parsed.description !== undefined) record.description = parsed.description;\n if (parsed.jobNumber !== undefined) record.jobNumber = parsed.jobNumber;\n if (parsed.jobTitle !== undefined) record.jobTitle = parsed.jobTitle;\n if (parsed.hours !== undefined) {\n record.hours = parsed.hours;\n record.durationHours = parsed.hours;\n }\n record.approvedBy = parsed.approvedBy || null;\n } catch (e) {\n console.error(e);\n }\n delete record.approved_by;\n } else if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.license_number !== undefined) { record.licenseNumber = record.license_number; delete record.license_number; }\n if (record.hourly_rate !== undefined) { record.hourlyRate = parseFloat(record.hourly_rate); delete record.hourly_rate; }\n if (record.after_hours_rate !== undefined) { record.afterHoursRate = parseFloat(record.after_hours_rate); delete record.after_hours_rate; }\n if (record.callout_fee !== undefined) { record.calloutFee = parseFloat(record.callout_fee); delete record.callout_fee; }\n if (record.compliance_docs !== undefined) { record.complianceDocs = record.compliance_docs; delete record.compliance_docs; }\n if (record.account_number !== undefined) { record.accountNumber = record.account_number; delete record.account_number; }\n if (record.payment_terms !== undefined) { record.paymentTerms = record.payment_terms; delete record.payment_terms; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.start_location !== undefined) { record.startLocation = record.start_location; delete record.start_location; }\n if (record.day_offset !== undefined) { record.dayOffset = parseInt(record.day_offset); delete record.day_offset; }\n if (record.start_hour !== undefined) { record.startHour = parseFloat(record.start_hour); delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = parseFloat(record.end_hour); delete record.end_hour; }\n\n if (record.color && record.color.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.color.substring(9));\n record.taskId = parsed.taskId;\n record.taskPath = parsed.taskId;\n record.taskName = parsed.taskName;\n record.color = parsed.color;\n if (parsed.date !== undefined) record.date = parsed.date;\n if (parsed.startTime !== undefined) record.startTime = parsed.startTime;\n if (parsed.finishTime !== undefined) record.finishTime = parsed.finishTime;\n if (parsed.hours !== undefined) record.hours = parsed.hours;\n if (parsed.startHour !== undefined && parsed.startHour !== null) record.startHour = parsed.startHour;\n if (parsed.endHour !== undefined && parsed.endHour !== null) record.endHour = parsed.endHour;\n if (parsed.dayOffset !== undefined && parsed.dayOffset !== null) record.dayOffset = parsed.dayOffset;\n if (parsed.type !== undefined) record.type = parsed.type;\n if (parsed.status !== undefined) record.status = parsed.status;\n if (parsed.notes !== undefined) record.notes = parsed.notes;\n if (parsed.jobTitle !== undefined) record.jobTitle = parsed.jobTitle;\n } catch (e) {\n console.error(e);\n }\n }\n if (collection === 'invoices') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.invoiceType = 'Standard';\n record.laborProfileId = '';\n record.issueDate = '';\n record.originalQuoteId = '';\n record.originalQuoteNumber = '';\n record.originalSubtotal = 0;\n record.approvedVariationsSum = 0;\n record.pendingVariationsSum = 0;\n record.totalInternalCost = 0;\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.invoiceType = meta.invoiceType || 'Standard';\n record.laborProfileId = meta.laborProfileId || '';\n record.issueDate = meta.issueDate || '';\n record.originalQuoteId = meta.originalQuoteId || '';\n record.originalQuoteNumber = meta.originalQuoteNumber || '';\n record.originalSubtotal = meta.originalSubtotal || 0;\n record.approvedVariationsSum = meta.approvedVariationsSum || 0;\n record.pendingVariationsSum = meta.pendingVariationsSum || 0;\n record.totalInternalCost = meta.totalInternalCost || 0;\n record.jobIds = meta.jobIds || []; // consolidated invoices link multiple jobs\n }\n }\n if (collection === 'quotes') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.laborProfileId = '';\n record.description = '';\n record.isTemplate = false;\n record.templateId = '';\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.laborProfileId = meta.laborProfileId || '';\n record.description = meta.description || '';\n record.isTemplate = meta.isTemplate || false;\n record.templateId = meta.templateId || '';\n }\n }\n if (collection === 'jobs') {\n if (record.notes && record.notes.startsWith('__meta__:')) {\n try {\n const meta = JSON.parse(record.notes.substring(9));\n record.isRecurring = meta.isRecurring || false;\n record.recurringConfig = meta.recurringConfig || null;\n record.preferredTime = meta.preferredTime || '';\n record.description = meta.description || '';\n record.parentJobId = meta.parentJobId || null;\n record.assetName = meta.assetName || '';\n record.notes = meta.notes || '';\n } catch (e) {\n console.error('Error parsing jobs meta:', e);\n }\n }\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (collection === 'contractors') {\n record.name = record.businessName;\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (collection === 'suppliers') {\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.xeroSalesAccountCode !== undefined) {\n record.xero_sales_account_code = record.xeroSalesAccountCode;\n delete record.xeroSalesAccountCode;\n }\n if (record.xeroExpenseAccountCode !== undefined) {\n record.xero_expense_account_code = record.xeroExpenseAccountCode;\n delete record.xeroExpenseAccountCode;\n }\n if (record.xeroTrackingCategoryName !== undefined) {\n record.xero_tracking_category_name = record.xeroTrackingCategoryName;\n delete record.xeroTrackingCategoryName;\n }\n if (record.xeroTrackingOptionName !== undefined) {\n record.xero_tracking_option_name = record.xeroTrackingOptionName;\n delete record.xeroTrackingOptionName;\n }\n if (record.deactivatedAt !== undefined) {\n record.deactivated_at = record.deactivatedAt;\n delete record.deactivatedAt;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.projectId !== undefined) {\n record.project_id = record.projectId;\n delete record.projectId;\n }\n if (record.costCenterId !== undefined) {\n record.cost_center_id = record.costCenterId;\n delete record.costCenterId;\n }\n if (record.startDate !== undefined) {\n record.start_date = record.startDate;\n delete record.startDate;\n }\n if (record.endDate !== undefined) {\n record.end_date = record.endDate;\n delete record.endDate;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.sections !== undefined && (collection === 'invoices' || collection === 'quotes')) {\n record.line_items = record.sections;\n delete record.sections;\n } else if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.paidDate !== undefined) {\n record.paid_date = record.paidDate;\n delete record.paidDate;\n }\n if (record.paymentMethod !== undefined) {\n record.payment_method = record.paymentMethod;\n delete record.paymentMethod;\n }\n if (record.stripeSessionId !== undefined) {\n record.stripe_session_id = record.stripeSessionId;\n delete record.stripeSessionId;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined || record.hours !== undefined) {\n const h = parseFloat(record.durationHours !== undefined ? record.durationHours : record.hours) || 0;\n record.duration_hours = h;\n record.durationHours = h;\n record.hours = h;\n }\n if (record.taskName !== undefined) record.task_name = record.taskName;\n if (record.taskId !== undefined) record.task_id = record.taskId;\n if (record.taskPath !== undefined) record.task_path = record.taskPath;\n if (record.phaseName !== undefined) record.phase_name = record.phaseName;\n if (record.phaseId !== undefined) record.phase_id = record.phaseId;\n if (record.startTime !== undefined) record.start_time = record.startTime;\n if (record.finishTime !== undefined) record.finish_time = record.finishTime;\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.licenseNumber !== undefined) { record.license_number = record.licenseNumber; delete record.licenseNumber; }\n if (record.hourlyRate !== undefined) { record.hourly_rate = record.hourlyRate; delete record.hourlyRate; }\n if (record.afterHoursRate !== undefined) { record.after_hours_rate = record.afterHoursRate; delete record.afterHoursRate; }\n if (record.calloutFee !== undefined) { record.callout_fee = record.calloutFee; delete record.calloutFee; }\n if (record.complianceDocs !== undefined) { record.compliance_docs = record.complianceDocs; delete record.complianceDocs; }\n if (record.accountNumber !== undefined) { record.account_number = record.accountNumber; delete record.accountNumber; }\n if (record.paymentTerms !== undefined) { record.payment_terms = record.paymentTerms; delete record.paymentTerms; }\n if (record.jobNumber !== undefined) { record.job_number = record.jobNumber; delete record.jobNumber; }\n if (record.startLocation !== undefined) { record.start_location = record.startLocation; delete record.startLocation; }\n if (record.dayOffset !== undefined) { record.day_offset = record.dayOffset; delete record.dayOffset; }\n if (record.startHour !== undefined) { record.start_hour = record.startHour; delete record.startHour; }\n if (record.endHour !== undefined) { record.end_hour = record.endHour; delete record.endHour; }\n\n if (collection === 'schedule') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || null,\n color: record.color || null,\n date: record.date || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n hours: record.hours || null,\n startHour: record.startHour !== undefined ? record.startHour : null,\n endHour: record.endHour !== undefined ? record.endHour : null,\n dayOffset: record.dayOffset !== undefined ? record.dayOffset : null,\n type: record.type || null,\n notes: record.notes || null,\n jobTitle: record.jobTitle || record.title || null\n };\n record.color = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'jobs') {\n const meta = {\n isRecurring: record.isRecurring || false,\n recurringConfig: record.recurringConfig || null,\n preferredTime: record.preferredTime || '',\n description: record.description || '',\n parentJobId: record.parentJobId || null,\n assetName: record.assetName || '',\n notes: record.notes || ''\n };\n record.notes = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'invoices') {\n const meta = {\n sections: record.sections || [],\n invoiceType: record.invoiceType || 'Standard',\n laborProfileId: record.laborProfileId || '',\n issueDate: record.issueDate || '',\n originalQuoteId: record.originalQuoteId || '',\n originalQuoteNumber: record.originalQuoteNumber || '',\n originalSubtotal: record.originalSubtotal || 0,\n approvedVariationsSum: record.approvedVariationsSum || 0,\n pendingVariationsSum: record.pendingVariationsSum || 0,\n totalInternalCost: record.totalInternalCost || 0,\n jobIds: record.jobIds || [] // consolidated invoices link multiple jobs\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n if (collection === 'quotes') {\n const meta = {\n sections: record.sections || [],\n laborProfileId: record.laborProfileId || '',\n description: record.description || '',\n isTemplate: record.isTemplate || false,\n templateId: record.templateId || ''\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n if (collection === 'timesheets') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || record.phaseName || null,\n taskPath: record.taskPath || null,\n phaseId: record.phaseId || null,\n phaseName: record.phaseName || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n description: record.description || null,\n hours: record.hours !== undefined ? record.hours : record.duration_hours,\n approvedBy: record.approvedBy || null,\n jobNumber: record.jobNumber || null,\n jobTitle: record.jobTitle || null\n };\n record.approved_by = '__meta__:' + JSON.stringify(meta);\n delete record.approvedBy;\n }\n\n if (collection === 'schedule') {\n record.job_id = record.jobId || null; delete record.jobId;\n record.job_number = record.jobNumber || null; delete record.jobNumber;\n record.technician_id = record.technicianId || null; delete record.technicianId;\n record.technician_name = record.technicianName || null; delete record.technicianName;\n record.start_hour = record.startHour !== undefined ? record.startHour : null; delete record.startHour;\n record.end_hour = record.endHour !== undefined ? record.endHour : null; delete record.endHour;\n record.day_offset = record.dayOffset !== undefined ? record.dayOffset : null; delete record.dayOffset;\n record.customer_name = record.customerName || null; delete record.customerName;\n record.site_address = record.siteAddress || null; delete record.siteAddress;\n record.task_id = record.taskId || null; delete record.taskId;\n record.task_name = record.taskName || null; delete record.taskName;\n record.start_time = record.startTime || null; delete record.startTime;\n record.finish_time = record.finishTime || null; delete record.finishTime;\n }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n const items = this.cache[collection] || [];\n if (collection === 'technicians') {\n const loginMode = typeof localStorage !== 'undefined' ? localStorage.getItem('relay_login_mode') : null;\n if (loginMode === 'local') {\n const currentUserStr = typeof localStorage !== 'undefined' ? localStorage.getItem('currentUser') : null;\n if (currentUserStr) {\n const currentUser = JSON.parse(currentUserStr);\n if (currentUser && currentUser.id) {\n return [{\n id: currentUser.id,\n name: currentUser.name || 'Local Admin',\n role: 'Administrator',\n color: currentUser.color || '#FF5C00',\n userTypeId: currentUser.userTypeId,\n email: currentUser.email || '',\n username: currentUser.username || ''\n }];\n }\n }\n }\n }\n return items;\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Surface a failed background write to the user instead of failing silently.\n // (A silent insert/update failure leaves the optimistic cache entry in place, so the\n // record looks saved until the next reload — the classic \"it didn't save\" symptom.)\n _notifyWriteError(action, collection, error) {\n console.error(`Error trying to ${action} ${collection}:`, error?.message || error, error);\n try {\n import('../components/Notifications.js')\n .then(({ showToast }) => showToast(\n `Couldn't ${action} ${collection.replace(/s$/, '')} — ${error?.message || 'database error'}`,\n 'error'))\n .catch(() => {});\n } catch (e) {}\n }\n\n // All jobs an invoice bills: single jobId and/or consolidated jobIds\n _invoiceJobIds(inv) {\n return [inv?.jobId, ...(inv?.jobIds || [])].filter(Boolean);\n }\n\n // Invoice lifecycle → job status sync. Jobs are only marked \"Invoiced\" when their\n // invoice actually leaves Draft (Sent/Paid/etc.) — NOT when a draft is created, so\n // abandoning/deleting a draft never strands jobs in the wrong status.\n _markJobsInvoiced(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (job && job.status !== 'Invoiced') this.update('jobs', jid, { status: 'Invoiced' });\n });\n }\n\n // When an invoice is deleted, revert any of its jobs still marked \"Invoiced\" —\n // unless another invoice also bills that job.\n _revertJobsOnInvoiceDelete(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (!job || job.status !== 'Invoiced') return;\n const billedElsewhere = (this.cache.invoices || []).some(other =>\n other.id !== inv.id && this._invoiceJobIds(other).includes(jid));\n if (!billedElsewhere) this.update('jobs', jid, { status: 'Completed' });\n });\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = this.getNextNumber('LD-', 'leads');\n }\n if (collection === 'notifications' && !item.number) {\n item.number = this.getNextNumber('NT-', 'notifications');\n }\n if (collection === 'invoices' && !item.number) {\n item.number = this.getNextNumber('INV-', 'invoices');\n }\n if (collection === 'quotes' && !item.number) {\n item.number = this.getNextNumber('Q-', 'quotes');\n }\n if (collection === 'jobs' && !item.number) {\n item.number = this.getNextNumber('J-', 'jobs');\n }\n if (collection === 'purchaseOrders' && !item.number) {\n item.number = this.getNextNumber('PO-', 'purchaseOrders');\n }\n if (collection === 'projects' && !item.number) {\n item.number = this.getNextNumber('PRJ-', 'projects');\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Rare path: an invoice created already past Draft marks its jobs immediately\n if (collection === 'invoices' && item.status && item.status !== 'Draft' && item.status !== 'Void') {\n this._markJobsInvoiced(item);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Local Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (this.folderSyncEnabled) {\n if (collection === 'documents' && item.url && item.url.startsWith('data:')) {\n this.writeDocumentFileToFolder(item.id, item.name, item.url).then(relPath => {\n if (relPath) {\n item.url = relPath;\n // Re-save in IndexedDB and rewrite folder collection with updated path\n this.writeRecordToIndexedDB(collection, item);\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n });\n } else {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n }\n this.writeRecordToIndexedDB(collection, item).catch(err => {\n console.error(`Error saving ${collection} to IndexedDB:`, err);\n });\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n item.email = email;\n\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) {\n // Roll back the optimistic add so the UI matches the database\n this.cache[collection] = (this.cache[collection] || []).filter(x => x.id !== item.id);\n this.emit(collection, this.cache[collection]);\n this._notifyWriteError('save', collection, error);\n }\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n // Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n this.writeRecordToIndexedDB(collection, updated).catch(err => {\n console.error(`Error saving updated ${collection} to IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const isAuthUpdate = updates.username !== undefined ||\n updates.email !== undefined ||\n updates.password !== undefined ||\n updates.name !== undefined ||\n updates.role !== undefined ||\n updates.userTypeId !== undefined ||\n updates.color !== undefined ||\n updates.payRate !== undefined;\n\n if (isAuthUpdate) {\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to update user.');\n }\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n const dbPayload = this.denormalizeRecord(updated, 'technicians');\n const { error: dbError } = await supabase\n .from('profiles')\n .update(dbPayload)\n .eq('id', id);\n\n if (dbError) {\n throw dbError;\n }\n\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 2b. Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Roll back to the previous version so the UI matches the database\n const arr = [...(this.cache[collection] || [])];\n const i = arr.findIndex(x => x.id === id);\n if (i !== -1) { arr[i] = previous; this.cache[collection] = arr; this.emit(collection, arr); }\n this._notifyWriteError('update', collection, error);\n }\n });\n }\n\n return updated;\n }\n\n // Child records that must not outlive their parent. Deleting a parent cascades to\n // these (scheduling/alert artifacts). Financial/labor records (invoices, timesheets)\n // are deliberately KEPT — they're history and carry their own denormalised numbers.\n _cascadeDelete(collection, id) {\n const CASCADES = {\n jobs: [ ['schedule', 'jobId'], ['formInstances', 'jobId'], ['notifications', 'jobId'] ],\n assets: [ ['maintenancePlans', 'assetId'], ['notifications', 'assetId'] ],\n formTemplates: [ ['formInstances', 'templateId'] ],\n };\n (CASCADES[collection] || []).forEach(([childCol, fk]) => {\n (this.cache[childCol] || []).filter(c => c[fk] === id).forEach(c => this.delete(childCol, c.id));\n });\n }\n\n delete(collection, id) {\n // 0. Cascade-remove dependent child records BEFORE the parent leaves the cache\n this._cascadeDelete(collection, id);\n\n // 1. Update memory cache (keep a copy of the removed row for rollback)\n const removed = (this.cache[collection] || []).find(item => item.id === id);\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Deleting an invoice frees any jobs it (alone) had marked \"Invoiced\"\n if (collection === 'invoices' && removed) {\n this._revertJobsOnInvoiceDelete(removed);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n this.deleteRecordFromIndexedDB(collection, id).catch(err => {\n console.error(`Error deleting ${id} from ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Restore the row we optimistically removed\n if (removed) { this.cache[collection] = [...(this.cache[collection] || []), removed]; this.emit(collection, this.cache[collection]); }\n this._notifyWriteError('delete', collection, error);\n }\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n /**\n * Generate the next sequential number for a collection.\n * Scans existing records, finds the highest numeric suffix, and returns prefix + (max+1) zero-padded to 5 digits.\n * e.g. getNextNumber('INV-', 'invoices') → 'INV-00001', 'INV-00002', ...\n */\n getNextNumber(defaultPrefix, collection) {\n const settings = this.getSettings();\n const dt = settings.documentTheme || {};\n \n let prefix = defaultPrefix;\n let startingNum = 1;\n\n if (collection === 'invoices') {\n prefix = dt.invoicePrefix !== undefined ? dt.invoicePrefix : 'INV-';\n startingNum = dt.invoiceStartingNumber !== undefined ? parseInt(dt.invoiceStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n } else if (collection === 'quotes') {\n prefix = dt.quotePrefix !== undefined ? dt.quotePrefix : 'Q-';\n startingNum = dt.quoteStartingNumber !== undefined ? parseInt(dt.quoteStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n }\n\n const items = this.getAll(collection) || [];\n let maxNum = startingNum - 1;\n\n items.forEach(item => {\n if (item.number && typeof item.number === 'string' && item.number.startsWith(prefix)) {\n const numStr = item.number.slice(prefix.length);\n const num = parseInt(numStr, 10);\n if (!isNaN(num) && num > maxNum) {\n maxNum = num;\n }\n }\n });\n\n return prefix + (maxNum + 1).toString().padStart(5, '0');\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: defaultLogoLarge,\n logoSmall: defaultLogoSmall,\n markupPercent: 20,\n taxEnabled: true,\n taxRate: 10,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n jobTypes: ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'],\n supplierCategories: ['Electrical', 'Plumbing', 'HVAC', 'Fire Safety', 'Security', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#FF5C00',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n },\n ai: {\n enabled: (this.companyId && !this.companyId.startsWith('acct_')) ? !!(typeof import.meta.env !== 'undefined' && (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY)) : false,\n apiKey: (this.companyId && !this.companyId.startsWith('acct_')) ? (typeof import.meta.env !== 'undefined' ? (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY || '') : '') : '',\n endpoint: 'https://api.openai.com/v1/chat/completions',\n model: 'gpt-4o-mini',\n systemPrompt: 'You are Relay, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default logos if user has not uploaded custom ones\n if (!merged.logo || merged.logo.includes('logo-large.png')) merged.logo = defaultLogoLarge;\n if (!merged.logoSmall) merged.logoSmall = defaultLogoSmall;\n if (!merged.jobTypes) merged.jobTypes = [...defaultSettings.jobTypes];\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n merged.ai = { ...defaultSettings.ai, ...this.companySettings.ai };\n return merged;\n }\n\n return defaultSettings;\n }\n\n getTaxRate() {\n const settings = this.getSettings();\n if (settings.taxEnabled === false) return 0;\n const rate = settings.taxRate !== undefined ? Number(settings.taxRate) : 10;\n return rate / 100;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('settings'), JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── v1.3 Maps: per-user dispatch start location ────────────────────────────\n // Writes profiles.start_location directly (the technicians update() path goes\n // through the invite-user edge function, which doesn't carry this field).\n // Pass null to clear the override (falls back to the company office).\n async setStartLocation(startLocation) {\n const currentUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null') : null;\n if (!currentUser?.id) throw new Error('Not signed in');\n\n // Update cache first so getStartLocation() sees it immediately\n const techs = [...(this.cache.technicians || [])];\n const i = techs.findIndex(t => t.id === currentUser.id);\n if (i !== -1) {\n techs[i] = { ...techs[i], startLocation };\n this.cache.technicians = techs;\n this.emit('technicians', techs);\n }\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n // Local mode: persist with the rest of the technician record\n await this.writeRecordToIndexedDB?.('technicians', techs[i]).catch?.(() => {});\n return;\n }\n const { error } = await supabase\n .from('profiles')\n .update({ start_location: startLocation })\n .eq('id', currentUser.id);\n if (error) {\n this._notifyWriteError('update', 'start location', error);\n throw error;\n }\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId && !this.companyId.startsWith('acct_')) return true;\n return localStorage.getItem(this.getStorageKey('seeded')) === 'true';\n }\n\n save(collection, items) {\n const prev = this.cache[collection] || [];\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const p = this.writeAllToIndexedDB(collection, items).catch(err => {\n console.error(`Error saving all items to ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, items);\n }\n return p;\n }\n\n // Cloud mode: persist the whole collection. Upsert every current row, then delete\n // any rows that were removed from the array. (Previously this only touched the\n // in-memory cache, so every save() — assign-tech, role edits, form templates,\n // portal edits, stock receiving, mark-read, etc. — silently failed to persist.)\n const table = TABLE_MAP[collection];\n if (!table) return; // cache-only collections (e.g. 'activity') have no table\n\n const payload = items\n .filter(it => it && it.id)\n .map(it => this.denormalizeRecord({ ...it, companyId: this.companyId }, collection));\n if (payload.length) {\n supabase.from(table).upsert(payload).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n\n const currentIds = new Set(items.map(it => it && it.id));\n const removedIds = prev.filter(p => p && p.id && !currentIds.has(p.id)).map(p => p.id);\n if (removedIds.length) {\n supabase.from(table).delete().in('id', removedIds).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n }\n\n markSeeded() {\n localStorage.setItem(this.getStorageKey('seeded'), 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('formTemplates', templates);\n return;\n }\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n async seedDefaultUserTypes() {\n if (!this.companyId) return;\n\n const userTypes = [\n {\n id: `${this.companyId}_ut_admin`,\n name: 'Admin',\n description: 'Full system access',\n permissions: buildGranularPerms(() => true)\n },\n {\n id: `${this.companyId}_ut_manager`,\n name: 'Manager',\n description: 'Can manage most workflows but limited settings access',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company', 'manage_tax'].includes(key);\n return true;\n })\n },\n {\n id: `${this.companyId}_ut_tech`,\n name: 'Technician',\n description: 'Field staff — limited to their own jobs, schedule and timesheets',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n {\n id: `${this.companyId}_ut_office`,\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders', 'Suppliers'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n\n this.cache.userTypes = userTypes;\n this.emit('userTypes', userTypes);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('userTypes', userTypes);\n return;\n }\n\n const dbPayload = userTypes.map(ut => this.denormalizeRecord({ ...ut, companyId: this.companyId }, 'userTypes'));\n const { error } = await supabase.from('user_types').insert(dbPayload);\n if (error) console.error('Error seeding default user types:', error);\n }\n\n async seedDefaultTechnicians() {\n const companyId = this.companyId;\n if (!companyId) return;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const managerTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_manager` : 'ut_manager';\n const techTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_tech` : 'ut_tech';\n const officeTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_office` : 'ut_office';\n\n const defaultTechs = [\n { id: `${companyId}_tech_1`, name: 'Jake Morrow', role: 'Senior Electrician', color: '#3B82F6', userTypeId: adminTypeId, payRate: 95.00, email: 'jake@apexpowerservices.com.au', phone: '0412 233 445', username: 'jake', password: '123456' },\n { id: `${companyId}_tech_2`, name: 'Ryan Holt', role: 'Service Manager', color: '#10B981', userTypeId: managerTypeId, payRate: 85.00, email: 'ryan@apexpowerservices.com.au', phone: '0423 344 556', username: 'ryan', password: '123456' },\n { id: `${companyId}_tech_3`, name: 'Sandra Okafor', role: 'Electrician', color: '#8B5CF6', userTypeId: techTypeId, payRate: 80.00, email: 'sandra@apexpowerservices.com.au', phone: '0434 455 667', username: 'sandra', password: '123456' },\n { id: `${companyId}_tech_4`, name: 'Dean Caruso', role: 'Office Administrator',color: '#F59E0B', userTypeId: officeTypeId, payRate: 50.00, email: 'dean@apexpowerservices.com.au', phone: '0445 566 778', username: 'dean', password: '123456' }\n ];\n\n this.cache.technicians = defaultTechs;\n this.emit('technicians', defaultTechs);\n\n if (companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('technicians', defaultTechs);\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder('technicians', defaultTechs).catch(err => {\n console.error('Error writing seeded technicians to local folder:', err);\n });\n }\n return;\n }\n\n const dbPayload = defaultTechs.map(t => this.denormalizeRecord({ ...t, companyId }, 'technicians'));\n const { error } = await supabase.from('technicians').insert(dbPayload);\n if (error) console.error('Error seeding default technicians:', error);\n }\n\n async migrateLocalToCloud(companyId, adminUserId) {\n this.companyId = companyId;\n this.userId = adminUserId;\n\n // 1. Update the remote company settings with local companySettings\n const currentSettings = this.getSettings();\n const { error: compErr } = await supabase.from('companies').update({\n abn: currentSettings.abn || '',\n phone: currentSettings.phone || '',\n domain: currentSettings.domain || '',\n email: currentSettings.email || '',\n address: currentSettings.address || '',\n settings: currentSettings\n }).eq('id', companyId);\n if (compErr) console.error('Error updating cloud company settings during migration:', compErr);\n\n // 2. Loop through all collections and push to Supabase\n // Skip 'companies' and 'technicians'\n const collectionsToMigrate = Object.keys(TABLE_MAP).filter(col => col !== 'companies' && col !== 'technicians');\n\n for (const col of collectionsToMigrate) {\n const items = this.cache[col] || [];\n if (items.length === 0) continue;\n\n const table = TABLE_MAP[col];\n if (!table) continue;\n\n // Assign the new cloud company_id and denormalize\n const payload = items.map(item => {\n const itemCopy = { ...item, companyId };\n return this.denormalizeRecord(itemCopy, col);\n });\n\n // Write to Supabase using upsert\n const { error } = await supabase.from(table).upsert(payload);\n if (error) {\n console.error(`Error migrating collection ${col}:`, error);\n throw new Error(`Failed to migrate ${col} data: ${error.message}`);\n }\n }\n\n // 3. Clear/close local DB connection and sync from Cloud\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n }\n\n deleteLocalAccountData(accountId) {\n return new Promise((resolve) => {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n if (typeof localStorage !== 'undefined') {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem(`relay_${accountId}_${col}`);\n });\n localStorage.removeItem(`relay_${accountId}_folder_sync_enabled`);\n localStorage.removeItem(`relay_${accountId}_seeded`);\n localStorage.removeItem(`relay_${accountId}_settings`);\n }\n if (typeof window !== 'undefined' && window.indexedDB) {\n const req = window.indexedDB.deleteDatabase(`RelayDispatchDB_${accountId}`);\n req.onsuccess = () => resolve();\n req.onerror = () => resolve();\n req.onblocked = () => {\n console.warn('Database deletion blocked, resolving anyway');\n resolve();\n };\n } else {\n resolve();\n }\n });\n }\n\n async clearAll() {\n this.clearSync();\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n \n if (activeAccount) {\n await this.deleteLocalAccountData(activeAccount);\n } else {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n this.dirHandle = null;\n try {\n await this.clearAllIndexedDB();\n } catch (err) {\n console.error('Error clearing IndexedDB:', err);\n }\n }\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "ee198590744bde66d79f1e2993f6020908498d5d": {"t": 1785754172.515951, "n": 117173, "h": "b20215c2cb12cffa", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\nconst defaultLogoLarge = new URL('../assets/RELAY_Dispatch_Logo.png', import.meta.url).href;\nconst defaultLogoSmall = new URL('../assets/logo-small.png', import.meta.url).href;\n\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n passwordResetRequests: 'password_reset_requests',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents',\n leads: 'leads',\n schedule: 'schedule',\n projects: 'projects',\n costCenters: 'cost_centers',\n deputyAsks: null\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"deactivated\",\n \"deactivated_at\",\n \"start_location\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"project_id\",\n \"cost_center_id\",\n \"created_at\",\n \"updated_at\"\n ],\n projects: [\n \"id\",\n \"company_id\",\n \"number\",\n \"name\",\n \"customer_id\",\n \"customer_name\",\n \"site_address\",\n \"status\",\n \"description\",\n \"start_date\",\n \"end_date\",\n \"created_at\",\n \"updated_at\"\n ],\n costCenters: [\n \"id\",\n \"company_id\",\n \"name\",\n \"code\",\n \"active\",\n \"xero_sales_account_code\",\n \"xero_expense_account_code\",\n \"xero_tracking_category_name\",\n \"xero_tracking_option_name\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"paid_date\",\n \"payment_method\",\n \"stripe_session_id\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"license_number\",\n \"hourly_rate\",\n \"after_hours_rate\",\n \"callout_fee\",\n \"specialties\",\n \"notes\",\n \"portal_token\",\n \"compliance_docs\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"address\",\n \"category\",\n \"account_number\",\n \"payment_terms\",\n \"notes\",\n \"attachments\",\n \"created_at\",\n \"updated_at\"\n ],\n leads: [\n \"id\",\n \"company_id\",\n \"number\",\n \"title\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"status\",\n \"source\",\n \"value\",\n \"description\",\n \"priority\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"created_at\",\n \"updated_at\"\n ],\n password_reset_requests: [\n \"id\",\n \"technician_id\",\n \"employee_id\",\n \"requested_at\",\n \"status\",\n \"token\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"type\",\n \"description\",\n \"priority\",\n \"read\",\n \"number\",\n \"asset_id\",\n \"job_id\",\n \"due_date\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g><text x=\"90\" y=\"36\" fill=\"#2D3134\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"25\" font-weight=\"900\" letter-spacing=\"0.04em\">DISPATCH</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nconst MODULE_PERMS = {\n 'Dashboard': [{ key: 'view' }],\n 'Customers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_contacts' }],\n 'Leads': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'convert' }],\n 'Quotes': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'approve' }, { key: 'convert' }, { key: 'generate_pdf' }],\n 'Jobs': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_tasks' }, { key: 'book_time' }, { key: 'view_costs' }, { key: 'view_quotes_tab' }, { key: 'view_pos_tab' }, { key: 'view_timesheets_tab' }, { key: 'view_invoices_tab' }, { key: 'manage_materials' }, { key: 'create_invoice' }],\n 'Timesheets': [{ key: 'view_own' }, { key: 'view' }, { key: 'create' }, { key: 'approve' }, { key: 'edit_all' }, { key: 'export' }],\n 'Assets': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Schedule': [{ key: 'view_own' }, { key: 'view' }, { key: 'edit' }],\n 'Contractors': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }],\n 'Suppliers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Stock': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Purchase Orders': [{ key: 'view' }, { key: 'create' }, { key: 'approve' }],\n 'Invoices': [{ key: 'view' }, { key: 'create' }, { key: 'send' }, { key: 'void' }],\n 'Reports': [{ key: 'view' }, { key: 'export' }],\n 'Documents': [{ key: 'view' }, { key: 'upload' }],\n 'Settings': [{ key: 'view' }, { key: 'edit_company' }, { key: 'manage_users' }, { key: 'manage_tax' }]\n};\n\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n this.db = null;\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n this.backupDirHandle = null;\n this.backupDirPermissionGranted = false;\n\n // Check if we have a cloud user at boot (before filling cache)\n const bootUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n const isCloudUser = !!(bootUser && bootUser.companyId && !bootUser.companyId.startsWith('acct_'));\n\n if (!isCloudUser && bootUser && bootUser.companyId && typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', bootUser.companyId);\n }\n\n // Pre-initialize empty arrays for collections.\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Load local settings if they exist (only for offline/demo mode)\n if (!isCloudUser && typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId && !this.companyId.startsWith('acct_')) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n // Only clear sync if we were previously in cloud sync mode or companyId is not defined\n if (!this.companyId || !this.companyId.startsWith('acct_')) {\n this.clearSync();\n }\n }\n });\n\n // Auto-trigger sync or local load if user already logged in at boot\n if (isCloudUser) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n this.initPromise = this.initializeCloudSync();\n } else {\n if (bootUser && bootUser.companyId) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n }\n this.initPromise = this.initializeLocalStore();\n }\n }\n\n getStorageKey(collection) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (activeAccount) {\n return `relay_${activeAccount}_${collection}`;\n }\n return `simpro_${collection}`;\n }\n\n getDBName() {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n return activeAccount ? `RelayDispatchDB_${activeAccount}` : 'RelayDispatchDB';\n }\n\n async initializeUser(user) {\n if (user && user.companyId && !user.companyId.startsWith('acct_')) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n } else {\n this.clearSync();\n if (user && user.companyId) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', user.companyId);\n }\n } else {\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n }\n this.initPromise = this.initializeLocalStore();\n await this.initPromise;\n }\n }\n\n async initializeLocalStore() {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n\n try {\n await this.initIndexedDB();\n } catch (e) {\n console.error('Failed to initialize IndexedDB, falling back to empty arrays:', e);\n }\n\n // Load local settings if they exist (namespaced)\n this.companySettings = null;\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n this.emit('settings', this.getSettings());\n\n // Seed default user types and forms if empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n if (this.cache.technicians.length === 0) {\n await this.seedDefaultTechnicians();\n }\n }\n\n initIndexedDB() {\n return new Promise((resolve, reject) => {\n if (typeof window === 'undefined' || !window.indexedDB) {\n return resolve();\n }\n\n const dbName = this.getDBName();\n const request = window.indexedDB.open(dbName, 4);\n\n request.onerror = (e) => {\n console.error('IndexedDB open error:', e.target.error);\n reject(e.target.error);\n };\n\n request.onsuccess = async (e) => {\n this.db = e.target.result;\n \n // 1. Load custom directory sync config from IndexedDB config store if it exists\n try {\n if (this.db.objectStoreNames.contains('config')) {\n await new Promise((resConfig) => {\n const transaction = this.db.transaction('config', 'readonly');\n const configStore = transaction.objectStore('config');\n \n const getSync = new Promise((resolve) => {\n const req = configStore.get('directory_handle');\n req.onsuccess = () => {\n console.log('[DEBUG Store] getSync onsuccess result:', req.result);\n if (req.result) {\n this.dirHandle = req.result.value;\n console.log('[DEBUG Store] loaded dirHandle:', this.dirHandle ? this.dirHandle.name : null);\n }\n resolve();\n };\n req.onerror = (e) => {\n console.error('[DEBUG Store] getSync onerror:', e);\n resolve();\n };\n });\n\n const getBackup = new Promise((resolve) => {\n const req = configStore.get('backup_directory_handle');\n req.onsuccess = () => {\n if (req.result) this.backupDirHandle = req.result.value;\n resolve();\n };\n req.onerror = () => resolve();\n });\n\n Promise.all([getSync, getBackup]).then(() => resConfig());\n });\n }\n } catch (err) {\n console.warn('Failed to read config from IndexedDB:', err);\n }\n\n // Verify directory permission silently on boot if handle exists\n if (this.dirHandle) {\n try {\n // Check permission silently without prompt dialog\n const opts = { mode: 'readwrite' };\n const status = await this.dirHandle.queryPermission(opts);\n this.folderSyncPermissionGranted = status === 'granted';\n } catch (err) {\n console.warn('Failed to query directory permission on boot:', err);\n }\n }\n\n // Verify backup directory permission silently on boot if handle exists\n if (this.backupDirHandle) {\n try {\n const opts = { mode: 'readwrite' };\n const status = await this.backupDirHandle.queryPermission(opts);\n this.backupDirPermissionGranted = status === 'granted';\n\n // Auto-trigger backup if granted and > 7 days since last backup\n if (this.backupDirPermissionGranted) {\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;\n if (!lastBackup || new Date(lastBackup).getTime() < sevenDaysAgo) {\n this.backupToFolder().catch(err => {\n console.error('Auto background backup failed:', err);\n });\n }\n }\n } catch (err) {\n console.warn('Failed to query backup directory permission on boot:', err);\n }\n }\n\n // Load all collections into cache\n const collections = Object.keys(TABLE_MAP);\n const loadPromises = collections.map(async (col) => {\n try {\n this.cache[col] = await this.readAllFromIndexedDB(col);\n } catch (err) {\n console.error(`Error loading collection ${col} from IndexedDB:`, err);\n this.cache[col] = [];\n }\n });\n\n await Promise.all(loadPromises);\n\n // Perform migration if we have old data in localStorage (only if not in a namespaced local account profile)\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (!activeAccount) {\n const migratePromises = collections.map(async (col) => {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n const items = JSON.parse(localData);\n if (Array.isArray(items) && items.length > 0) {\n // Write all items to IndexedDB\n await this.writeAllToIndexedDB(col, items);\n // Merge into cache if cache is empty\n if (this.cache[col].length === 0) {\n this.cache[col] = items;\n }\n }\n localStorage.removeItem('simpro_' + col);\n } catch (err) {\n console.error(`Migration error for ${col}:`, err);\n }\n }\n });\n\n await Promise.all(migratePromises);\n }\n resolve();\n };\n\n request.onupgradeneeded = (e) => {\n const db = e.target.result;\n const collections = Object.keys(TABLE_MAP);\n collections.forEach(col => {\n if (!db.objectStoreNames.contains(col)) {\n db.createObjectStore(col, { keyPath: 'id' });\n }\n });\n if (!db.objectStoreNames.contains('config')) {\n db.createObjectStore('config', { keyPath: 'key' });\n }\n };\n });\n }\n\n readAllFromIndexedDB(storeName) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve([]);\n try {\n const transaction = this.db.transaction(storeName, 'readonly');\n const store = transaction.objectStore(storeName);\n const getAllRequest = store.getAll();\n\n getAllRequest.onsuccess = () => {\n resolve(getAllRequest.result || []);\n };\n\n getAllRequest.onerror = (e) => {\n reject(e.target.error);\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeRecordToIndexedDB(storeName, record) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const putRequest = store.put(record);\n\n putRequest.onsuccess = () => resolve();\n putRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n deleteRecordFromIndexedDB(storeName, id) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const deleteRequest = store.delete(id);\n\n deleteRequest.onsuccess = () => resolve();\n deleteRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeAllToIndexedDB(storeName, items) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n \n // Clear existing\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n \n clearRequest.onsuccess = () => {\n let count = 0;\n if (items.length === 0) return resolve();\n \n items.forEach(item => {\n const putRequest = store.put(item);\n putRequest.onerror = (e) => reject(e.target.error);\n putRequest.onsuccess = () => {\n count++;\n if (count === items.length) {\n resolve();\n }\n };\n });\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n clearAllIndexedDB() {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const collections = Object.keys(TABLE_MAP);\n const transaction = this.db.transaction(collections, 'readwrite');\n let count = 0;\n \n collections.forEach(col => {\n const store = transaction.objectStore(col);\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n clearRequest.onsuccess = () => {\n count++;\n if (count === collections.length) {\n resolve();\n }\n };\n });\n } catch (err) {\n reject(err);\n }\n });\n }\n\n // ── Local Folder Sync / File System Access Driver ──────────────────────────\n\n async verifyDirPermission(readWrite) {\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n this.folderSyncPermissionGranted = true;\n return true; // Capacitor native has permissions checked at app level\n }\n\n if (!this.dirHandle) return false;\n const opts = {};\n if (readWrite) opts.mode = 'readwrite';\n \n try {\n const currentPerm = await this.dirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.folderSyncPermissionGranted = true;\n return true;\n }\n \n // Request permission (must be called within a user gesture handler)\n const newPerm = await this.dirHandle.requestPermission(opts);\n this.folderSyncPermissionGranted = newPerm === 'granted';\n return this.folderSyncPermissionGranted;\n } catch (e) {\n this.folderSyncPermissionGranted = false;\n return false;\n }\n }\n\n async verifyBackupDirPermission(requestInteractive) {\n if (!this.backupDirHandle) return false;\n const opts = { mode: 'readwrite' };\n try {\n const currentPerm = await this.backupDirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.backupDirPermissionGranted = true;\n return true;\n }\n if (requestInteractive) {\n const newPerm = await this.backupDirHandle.requestPermission(opts);\n this.backupDirPermissionGranted = newPerm === 'granted';\n return this.backupDirPermissionGranted;\n }\n this.backupDirPermissionGranted = false;\n return false;\n } catch (e) {\n this.backupDirPermissionGranted = false;\n return false;\n }\n }\n\n // Ensure a subfolder for the current company exists within the selected directory.\n async ensureCompanyFolderHandle(customDirHandle) {\n const baseHandle = customDirHandle || this.dirHandle;\n if (!baseHandle) return null;\n const rawName = this.companySettings?.name || this.companySettings?.id || 'Company';\n const safeName = rawName.replace(/[\\\\/:*?\"<>|]/g, '_');\n try {\n const companyHandle = await baseHandle.getDirectoryHandle(safeName, { create: true });\n return companyHandle;\n } catch (e) {\n console.error('Failed to get/create company folder:', e);\n return null;\n }\n }\n\n async setBackupDirectory(dirHandle) {\n this.backupDirHandle = dirHandle || null;\n this.backupDirPermissionGranted = !!dirHandle;\n \n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n if (dirHandle) {\n const putReq = configStore.put({ key: 'backup_directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = () => resolve();\n } else {\n const deleteReq = configStore.delete('backup_directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n }\n });\n }\n }\n\n async backupToFolder(dirHandle) {\n const handle = dirHandle || this.backupDirHandle;\n if (!handle) throw new Error('No backup directory configured.');\n \n const currentPerm = await handle.queryPermission({ mode: 'readwrite' });\n if (currentPerm !== 'granted') {\n const newPerm = await handle.requestPermission({ mode: 'readwrite' });\n if (newPerm !== 'granted') {\n throw new Error('Write permission denied for backup directory.');\n }\n }\n \n // Ensure company-specific folder exists within the selected directory\n const companyFolderHandle = await this.ensureCompanyFolderHandle(dirHandle);\n const dataDir = await companyFolderHandle.getDirectoryHandle('data', { create: true });\n const collections = Object.keys(TABLE_MAP);\n for (const col of collections) {\n const items = this.cache[col] || [];\n const fileHandle = await dataDir.getFileHandle(`${col}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items, null, 2));\n await writable.close();\n }\n \n // Update last backup timestamp\n localStorage.setItem('relay_last_backup_time', new Date().toISOString());\n this.backupDirPermissionGranted = true;\n }\n\n async setLocalDirectory(dirHandle) {\n this.dirHandle = dirHandle;\n if (dirHandle) {\n this.folderSyncEnabled = true;\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('folder_sync_enabled'), 'true');\n }\n \n // Save handle to IndexedDB\n if (this.db) {\n await new Promise((resolve, reject) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const putReq = configStore.put({ key: 'directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = (e) => reject(e.target.error);\n });\n }\n \n this.folderSyncPermissionGranted = true;\n \n // Force rewrite all cached collections to directory immediately\n const collections = Object.keys(TABLE_MAP);\n await Promise.all(collections.map(col => this.writeCollectionToFolder(col, this.cache[col])));\n } else {\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n \n // Remove handle from IndexedDB\n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const deleteReq = configStore.delete('directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n });\n }\n }\n }\n\n async writeCollectionToFolder(collection, items) {\n if (!this.folderSyncEnabled) return;\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/data/${collection}.json`,\n data: JSON.stringify(items || [], null, 2),\n directory: 'DOCUMENTS',\n encoding: 'utf8',\n recursive: true\n });\n } catch (err) {\n console.error(`Capacitor failed to write collection ${collection}:`, err);\n }\n return;\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return;\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return;\n const dataDir = await companyFolder.getDirectoryHandle('data', { create: true });\n const fileHandle = await dataDir.getFileHandle(`${collection}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items || [], null, 2));\n await writable.close();\n } catch (err) {\n console.error(`Failed to write collection ${collection} to local directory:`, err);\n }\n }\n\n async writeDocumentFileToFolder(docId, name, dataUrl) {\n if (!this.folderSyncEnabled) return null;\n\n const ext = name.includes('.') ? name.split('.').pop() : 'bin';\n const fileName = `${docId}.${ext}`;\n const relativePath = `documents/${fileName}`;\n\n // Extract base64 payload\n const base64Parts = dataUrl.split(',');\n if (base64Parts.length < 2) return null;\n const base64Data = base64Parts[1];\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/documents/${fileName}`,\n data: base64Data,\n directory: 'DOCUMENTS',\n recursive: true\n });\n return relativePath;\n } catch (err) {\n console.error('Capacitor failed to write document:', err);\n return null;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return null;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return null;\n\n const mimeMatch = base64Parts[0].match(/:(.*?);/);\n const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';\n const binaryStr = atob(base64Data);\n const len = binaryStr.length;\n const bytes = new Uint8Array(len);\n for (let i = 0; i < len; i++) {\n bytes[i] = binaryStr.charCodeAt(i);\n }\n const blob = new Blob([bytes], { type: mime });\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return null;\n const docsDir = await companyFolder.getDirectoryHandle('documents', { create: true });\n const fileHandle = await docsDir.getFileHandle(fileName, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(blob);\n await writable.close();\n\n return relativePath;\n } catch (err) {\n console.error('Failed to save document file to local directory:', err);\n return null;\n }\n }\n\n async getDocumentUrl(doc) {\n if (!doc || !doc.url) return '';\n if (doc.url.startsWith('data:') || doc.url.startsWith('http')) {\n return doc.url;\n }\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n const result = await Filesystem.readFile({\n path: `RelayDispatchData/${doc.url}`,\n directory: 'DOCUMENTS'\n });\n const mime = doc.type || 'application/octet-stream';\n return `data:${mime};base64,${result.data}`;\n } catch (err) {\n console.error('Capacitor failed to read document file:', err);\n throw err;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) {\n throw new Error('Local directory sync is not configured.');\n }\n\n try {\n const hasPerm = await this.verifyDirPermission(false);\n if (!hasPerm) {\n throw new Error('Permission to access the local directory was not granted.');\n }\n\n const parts = doc.url.split('/');\n const dirName = parts[0];\n const fileName = parts[1];\n\n const dir = await this.dirHandle.getDirectoryHandle(dirName);\n const fileHandle = await dir.getFileHandle(fileName);\n const file = await fileHandle.getFile();\n return URL.createObjectURL(file);\n } catch (err) {\n console.error('Failed to read file from local directory:', err);\n throw err;\n }\n }\n\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // Initialize IndexedDB for cloud users too, to store backup directory handles\n await this.initIndexedDB();\n\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id\n query = query.eq('company_id', this.companyId);\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n await this.writeAllToIndexedDB(col, this.cache[col]);\n }\n });\n\n await Promise.all(promises);\n\n // Seed default user types if database is empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n // Unique channel name per subscribe — reusing a fixed name throws\n // \"cannot add postgres_changes callbacks ... after subscribe()\" when sync re-inits.\n const channel = supabase\n .channel(`table-db-changes-${this.companyId || 'anon'}-${Date.now()}`)\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n if (collection === 'contractors') {\n record.businessName = record.name;\n record.active = record.status === 'Active';\n }\n\n if (collection === 'suppliers') {\n record.active = record.status === 'Active';\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.project_id !== undefined) {\n record.projectId = record.project_id;\n delete record.project_id;\n }\n if (record.cost_center_id !== undefined) {\n record.costCenterId = record.cost_center_id;\n delete record.cost_center_id;\n }\n if (record.xero_sales_account_code !== undefined) {\n record.xeroSalesAccountCode = record.xero_sales_account_code;\n delete record.xero_sales_account_code;\n }\n if (record.xero_expense_account_code !== undefined) {\n record.xeroExpenseAccountCode = record.xero_expense_account_code;\n delete record.xero_expense_account_code;\n }\n if (record.xero_tracking_category_name !== undefined) {\n record.xeroTrackingCategoryName = record.xero_tracking_category_name;\n delete record.xero_tracking_category_name;\n }\n if (record.xero_tracking_option_name !== undefined) {\n record.xeroTrackingOptionName = record.xero_tracking_option_name;\n delete record.xero_tracking_option_name;\n }\n if (record.deactivated_at !== undefined) {\n record.deactivatedAt = record.deactivated_at;\n delete record.deactivated_at;\n }\n if (record.start_date !== undefined) {\n record.startDate = record.start_date;\n delete record.start_date;\n }\n if (record.end_date !== undefined) {\n record.endDate = record.end_date;\n delete record.end_date;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (collection === 'schedule') {\n if (record.job_id !== undefined) { record.jobId = record.job_id; delete record.job_id; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.technician_id !== undefined) { record.technicianId = record.technician_id; delete record.technician_id; }\n if (record.technician_name !== undefined) { record.technicianName = record.technician_name; delete record.technician_name; }\n if (record.start_hour !== undefined) { record.startHour = record.start_hour != null ? parseFloat(record.start_hour) : null; delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = record.end_hour != null ? parseFloat(record.end_hour) : null; delete record.end_hour; }\n if (record.day_offset !== undefined) { record.dayOffset = record.day_offset; delete record.day_offset; }\n if (record.customer_name !== undefined) { record.customerName = record.customer_name; delete record.customer_name; }\n if (record.site_address !== undefined) { record.siteAddress = record.site_address; delete record.site_address; }\n }\n\n if (record.line_items !== undefined) {\n if (collection === 'invoices' || collection === 'quotes') {\n const items = record.line_items;\n if (Array.isArray(items) && items.length > 0 && (items[0].lineItems !== undefined || items[0].items !== undefined || (items[0].name !== undefined && items[0].qty === undefined))) {\n record.sections = items;\n } else {\n record.lineItems = items;\n }\n } else {\n record.lineItems = record.line_items;\n }\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.paid_date !== undefined) {\n record.paidDate = record.paid_date;\n delete record.paid_date;\n }\n if (record.payment_method !== undefined) {\n record.paymentMethod = record.payment_method;\n delete record.payment_method;\n }\n if (record.stripe_session_id !== undefined) {\n record.stripeSessionId = record.stripe_session_id;\n delete record.stripe_session_id;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined || record.durationHours !== undefined || record.hours !== undefined) {\n const h = parseFloat(record.duration_hours !== undefined ? record.duration_hours : (record.durationHours !== undefined ? record.durationHours : record.hours)) || 0;\n record.durationHours = h;\n record.hours = h;\n delete record.duration_hours;\n }\n if (record.task_name !== undefined) { record.taskName = record.task_name; delete record.task_name; }\n if (record.task_id !== undefined) { record.taskId = record.task_id; delete record.task_id; }\n if (record.task_path !== undefined) { record.taskPath = record.task_path; delete record.task_path; }\n if (record.phase_name !== undefined) { record.phaseName = record.phase_name; delete record.phase_name; }\n if (record.phase_id !== undefined) { record.phaseId = record.phase_id; delete record.phase_id; }\n if (record.start_time !== undefined) { record.startTime = record.start_time; delete record.start_time; }\n if (record.finish_time !== undefined) { record.finishTime = record.finish_time; delete record.finish_time; }\n if (record.approved_by && typeof record.approved_by === 'string' && record.approved_by.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.approved_by.substring(9));\n if (parsed.taskId !== undefined) record.taskId = parsed.taskId;\n if (parsed.taskName !== undefined) record.taskName = parsed.taskName;\n if (parsed.taskPath !== undefined) record.taskPath = parsed.taskPath;\n if (parsed.phaseId !== undefined) record.phaseId = parsed.phaseId;\n if (parsed.phaseName !== undefined) record.phaseName = parsed.phaseName;\n if (parsed.startTime !== undefined) record.startTime = parsed.startTime;\n if (parsed.finishTime !== undefined) record.finishTime = parsed.finishTime;\n if (parsed.description !== undefined) record.description = parsed.description;\n if (parsed.jobNumber !== undefined) record.jobNumber = parsed.jobNumber;\n if (parsed.jobTitle !== undefined) record.jobTitle = parsed.jobTitle;\n if (parsed.hours !== undefined) {\n record.hours = parsed.hours;\n record.durationHours = parsed.hours;\n }\n record.approvedBy = parsed.approvedBy || null;\n } catch (e) {\n console.error(e);\n }\n delete record.approved_by;\n } else if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.license_number !== undefined) { record.licenseNumber = record.license_number; delete record.license_number; }\n if (record.hourly_rate !== undefined) { record.hourlyRate = parseFloat(record.hourly_rate); delete record.hourly_rate; }\n if (record.after_hours_rate !== undefined) { record.afterHoursRate = parseFloat(record.after_hours_rate); delete record.after_hours_rate; }\n if (record.callout_fee !== undefined) { record.calloutFee = parseFloat(record.callout_fee); delete record.callout_fee; }\n if (record.compliance_docs !== undefined) { record.complianceDocs = record.compliance_docs; delete record.compliance_docs; }\n if (record.account_number !== undefined) { record.accountNumber = record.account_number; delete record.account_number; }\n if (record.payment_terms !== undefined) { record.paymentTerms = record.payment_terms; delete record.payment_terms; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.start_location !== undefined) { record.startLocation = record.start_location; delete record.start_location; }\n if (record.day_offset !== undefined) { record.dayOffset = parseInt(record.day_offset); delete record.day_offset; }\n if (record.start_hour !== undefined) { record.startHour = parseFloat(record.start_hour); delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = parseFloat(record.end_hour); delete record.end_hour; }\n\n if (record.color && record.color.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.color.substring(9));\n record.taskId = parsed.taskId;\n record.taskPath = parsed.taskId;\n record.taskName = parsed.taskName;\n record.color = parsed.color;\n if (parsed.date !== undefined) record.date = parsed.date;\n if (parsed.startTime !== undefined) record.startTime = parsed.startTime;\n if (parsed.finishTime !== undefined) record.finishTime = parsed.finishTime;\n if (parsed.hours !== undefined) record.hours = parsed.hours;\n if (parsed.startHour !== undefined && parsed.startHour !== null) record.startHour = parsed.startHour;\n if (parsed.endHour !== undefined && parsed.endHour !== null) record.endHour = parsed.endHour;\n if (parsed.dayOffset !== undefined && parsed.dayOffset !== null) record.dayOffset = parsed.dayOffset;\n if (parsed.type !== undefined) record.type = parsed.type;\n if (parsed.status !== undefined) record.status = parsed.status;\n if (parsed.notes !== undefined) record.notes = parsed.notes;\n } catch (e) {\n console.error(e);\n }\n }\n if (collection === 'invoices') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.invoiceType = 'Standard';\n record.laborProfileId = '';\n record.issueDate = '';\n record.originalQuoteId = '';\n record.originalQuoteNumber = '';\n record.originalSubtotal = 0;\n record.approvedVariationsSum = 0;\n record.pendingVariationsSum = 0;\n record.totalInternalCost = 0;\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.invoiceType = meta.invoiceType || 'Standard';\n record.laborProfileId = meta.laborProfileId || '';\n record.issueDate = meta.issueDate || '';\n record.originalQuoteId = meta.originalQuoteId || '';\n record.originalQuoteNumber = meta.originalQuoteNumber || '';\n record.originalSubtotal = meta.originalSubtotal || 0;\n record.approvedVariationsSum = meta.approvedVariationsSum || 0;\n record.pendingVariationsSum = meta.pendingVariationsSum || 0;\n record.totalInternalCost = meta.totalInternalCost || 0;\n record.jobIds = meta.jobIds || []; // consolidated invoices link multiple jobs\n }\n }\n if (collection === 'quotes') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.laborProfileId = '';\n record.description = '';\n record.isTemplate = false;\n record.templateId = '';\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.laborProfileId = meta.laborProfileId || '';\n record.description = meta.description || '';\n record.isTemplate = meta.isTemplate || false;\n record.templateId = meta.templateId || '';\n }\n }\n if (collection === 'jobs') {\n if (record.notes && record.notes.startsWith('__meta__:')) {\n try {\n const meta = JSON.parse(record.notes.substring(9));\n record.isRecurring = meta.isRecurring || false;\n record.recurringConfig = meta.recurringConfig || null;\n record.preferredTime = meta.preferredTime || '';\n record.description = meta.description || '';\n record.parentJobId = meta.parentJobId || null;\n record.assetName = meta.assetName || '';\n record.notes = meta.notes || '';\n } catch (e) {\n console.error('Error parsing jobs meta:', e);\n }\n }\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (collection === 'contractors') {\n record.name = record.businessName;\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (collection === 'suppliers') {\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.xeroSalesAccountCode !== undefined) {\n record.xero_sales_account_code = record.xeroSalesAccountCode;\n delete record.xeroSalesAccountCode;\n }\n if (record.xeroExpenseAccountCode !== undefined) {\n record.xero_expense_account_code = record.xeroExpenseAccountCode;\n delete record.xeroExpenseAccountCode;\n }\n if (record.xeroTrackingCategoryName !== undefined) {\n record.xero_tracking_category_name = record.xeroTrackingCategoryName;\n delete record.xeroTrackingCategoryName;\n }\n if (record.xeroTrackingOptionName !== undefined) {\n record.xero_tracking_option_name = record.xeroTrackingOptionName;\n delete record.xeroTrackingOptionName;\n }\n if (record.deactivatedAt !== undefined) {\n record.deactivated_at = record.deactivatedAt;\n delete record.deactivatedAt;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.projectId !== undefined) {\n record.project_id = record.projectId;\n delete record.projectId;\n }\n if (record.costCenterId !== undefined) {\n record.cost_center_id = record.costCenterId;\n delete record.costCenterId;\n }\n if (record.startDate !== undefined) {\n record.start_date = record.startDate;\n delete record.startDate;\n }\n if (record.endDate !== undefined) {\n record.end_date = record.endDate;\n delete record.endDate;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.sections !== undefined && (collection === 'invoices' || collection === 'quotes')) {\n record.line_items = record.sections;\n delete record.sections;\n } else if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.paidDate !== undefined) {\n record.paid_date = record.paidDate;\n delete record.paidDate;\n }\n if (record.paymentMethod !== undefined) {\n record.payment_method = record.paymentMethod;\n delete record.paymentMethod;\n }\n if (record.stripeSessionId !== undefined) {\n record.stripe_session_id = record.stripeSessionId;\n delete record.stripeSessionId;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined || record.hours !== undefined) {\n const h = parseFloat(record.durationHours !== undefined ? record.durationHours : record.hours) || 0;\n record.duration_hours = h;\n record.durationHours = h;\n record.hours = h;\n }\n if (record.taskName !== undefined) record.task_name = record.taskName;\n if (record.taskId !== undefined) record.task_id = record.taskId;\n if (record.taskPath !== undefined) record.task_path = record.taskPath;\n if (record.phaseName !== undefined) record.phase_name = record.phaseName;\n if (record.phaseId !== undefined) record.phase_id = record.phaseId;\n if (record.startTime !== undefined) record.start_time = record.startTime;\n if (record.finishTime !== undefined) record.finish_time = record.finishTime;\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.licenseNumber !== undefined) { record.license_number = record.licenseNumber; delete record.licenseNumber; }\n if (record.hourlyRate !== undefined) { record.hourly_rate = record.hourlyRate; delete record.hourlyRate; }\n if (record.afterHoursRate !== undefined) { record.after_hours_rate = record.afterHoursRate; delete record.afterHoursRate; }\n if (record.calloutFee !== undefined) { record.callout_fee = record.calloutFee; delete record.calloutFee; }\n if (record.complianceDocs !== undefined) { record.compliance_docs = record.complianceDocs; delete record.complianceDocs; }\n if (record.accountNumber !== undefined) { record.account_number = record.accountNumber; delete record.accountNumber; }\n if (record.paymentTerms !== undefined) { record.payment_terms = record.paymentTerms; delete record.paymentTerms; }\n if (record.jobNumber !== undefined) { record.job_number = record.jobNumber; delete record.jobNumber; }\n if (record.startLocation !== undefined) { record.start_location = record.startLocation; delete record.startLocation; }\n if (record.dayOffset !== undefined) { record.day_offset = record.dayOffset; delete record.dayOffset; }\n if (record.startHour !== undefined) { record.start_hour = record.startHour; delete record.startHour; }\n if (record.endHour !== undefined) { record.end_hour = record.endHour; delete record.endHour; }\n\n if (collection === 'schedule') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || null,\n color: record.color || null,\n date: record.date || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n hours: record.hours || null,\n startHour: record.startHour !== undefined ? record.startHour : null,\n endHour: record.endHour !== undefined ? record.endHour : null,\n dayOffset: record.dayOffset !== undefined ? record.dayOffset : null\n };\n record.color = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'jobs') {\n const meta = {\n isRecurring: record.isRecurring || false,\n recurringConfig: record.recurringConfig || null,\n preferredTime: record.preferredTime || '',\n description: record.description || '',\n parentJobId: record.parentJobId || null,\n assetName: record.assetName || '',\n notes: record.notes || ''\n };\n record.notes = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'invoices') {\n const meta = {\n sections: record.sections || [],\n invoiceType: record.invoiceType || 'Standard',\n laborProfileId: record.laborProfileId || '',\n issueDate: record.issueDate || '',\n originalQuoteId: record.originalQuoteId || '',\n originalQuoteNumber: record.originalQuoteNumber || '',\n originalSubtotal: record.originalSubtotal || 0,\n approvedVariationsSum: record.approvedVariationsSum || 0,\n pendingVariationsSum: record.pendingVariationsSum || 0,\n totalInternalCost: record.totalInternalCost || 0,\n jobIds: record.jobIds || [] // consolidated invoices link multiple jobs\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n if (collection === 'quotes') {\n const meta = {\n sections: record.sections || [],\n laborProfileId: record.laborProfileId || '',\n description: record.description || '',\n isTemplate: record.isTemplate || false,\n templateId: record.templateId || ''\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n if (collection === 'timesheets') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || record.phaseName || null,\n taskPath: record.taskPath || null,\n phaseId: record.phaseId || null,\n phaseName: record.phaseName || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n description: record.description || null,\n hours: record.hours !== undefined ? record.hours : record.duration_hours,\n approvedBy: record.approvedBy || null,\n jobNumber: record.jobNumber || null,\n jobTitle: record.jobTitle || null\n };\n record.approved_by = '__meta__:' + JSON.stringify(meta);\n delete record.approvedBy;\n }\n\n if (collection === 'schedule') {\n const meta = {\n taskId: record.taskId || null,\n taskName: record.taskName || null,\n color: record.color || null,\n date: record.date || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n hours: record.hours !== undefined ? record.hours : null,\n startHour: record.startHour !== undefined ? record.startHour : null,\n endHour: record.endHour !== undefined ? record.endHour : null,\n dayOffset: record.dayOffset !== undefined ? record.dayOffset : null,\n type: record.type || null,\n status: record.status || null,\n notes: record.notes || null\n };\n\n record.job_id = record.jobId || null; delete record.jobId;\n record.job_number = record.jobNumber || null; delete record.jobNumber;\n record.technician_id = record.technicianId || null; delete record.technicianId;\n record.technician_name = record.technicianName || null; delete record.technicianName;\n record.start_hour = record.startHour !== undefined ? record.startHour : null; delete record.startHour;\n record.end_hour = record.endHour !== undefined ? record.endHour : null; delete record.endHour;\n record.day_offset = record.dayOffset !== undefined ? record.dayOffset : null; delete record.dayOffset;\n record.customer_name = record.customerName || null; delete record.customerName;\n record.site_address = record.siteAddress || null; delete record.siteAddress;\n \n record.color = '__meta__:' + JSON.stringify(meta);\n \n delete record.date;\n delete record.type;\n delete record.status;\n delete record.notes;\n delete record.startTime;\n delete record.finishTime;\n delete record.hours;\n delete record.taskId;\n delete record.taskName;\n }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n const items = this.cache[collection] || [];\n if (collection === 'technicians') {\n const loginMode = typeof localStorage !== 'undefined' ? localStorage.getItem('relay_login_mode') : null;\n if (loginMode === 'local') {\n const currentUserStr = typeof localStorage !== 'undefined' ? localStorage.getItem('currentUser') : null;\n if (currentUserStr) {\n const currentUser = JSON.parse(currentUserStr);\n if (currentUser && currentUser.id) {\n return [{\n id: currentUser.id,\n name: currentUser.name || 'Local Admin',\n role: 'Administrator',\n color: currentUser.color || '#FF5C00',\n userTypeId: currentUser.userTypeId,\n email: currentUser.email || '',\n username: currentUser.username || ''\n }];\n }\n }\n }\n }\n return items;\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Surface a failed background write to the user instead of failing silently.\n // (A silent insert/update failure leaves the optimistic cache entry in place, so the\n // record looks saved until the next reload — the classic \"it didn't save\" symptom.)\n _notifyWriteError(action, collection, error) {\n console.error(`Error trying to ${action} ${collection}:`, error?.message || error, error);\n try {\n import('../components/Notifications.js')\n .then(({ showToast }) => showToast(\n `Couldn't ${action} ${collection.replace(/s$/, '')} — ${error?.message || 'database error'}`,\n 'error'))\n .catch(() => {});\n } catch (e) {}\n }\n\n // All jobs an invoice bills: single jobId and/or consolidated jobIds\n _invoiceJobIds(inv) {\n return [inv?.jobId, ...(inv?.jobIds || [])].filter(Boolean);\n }\n\n // Invoice lifecycle → job status sync. Jobs are only marked \"Invoiced\" when their\n // invoice actually leaves Draft (Sent/Paid/etc.) — NOT when a draft is created, so\n // abandoning/deleting a draft never strands jobs in the wrong status.\n _markJobsInvoiced(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (job && job.status !== 'Invoiced') this.update('jobs', jid, { status: 'Invoiced' });\n });\n }\n\n // When an invoice is deleted, revert any of its jobs still marked \"Invoiced\" —\n // unless another invoice also bills that job.\n _revertJobsOnInvoiceDelete(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (!job || job.status !== 'Invoiced') return;\n const billedElsewhere = (this.cache.invoices || []).some(other =>\n other.id !== inv.id && this._invoiceJobIds(other).includes(jid));\n if (!billedElsewhere) this.update('jobs', jid, { status: 'Completed' });\n });\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = this.getNextNumber('LD-', 'leads');\n }\n if (collection === 'notifications' && !item.number) {\n item.number = this.getNextNumber('NT-', 'notifications');\n }\n if (collection === 'invoices' && !item.number) {\n item.number = this.getNextNumber('INV-', 'invoices');\n }\n if (collection === 'quotes' && !item.number) {\n item.number = this.getNextNumber('Q-', 'quotes');\n }\n if (collection === 'jobs' && !item.number) {\n item.number = this.getNextNumber('J-', 'jobs');\n }\n if (collection === 'purchaseOrders' && !item.number) {\n item.number = this.getNextNumber('PO-', 'purchaseOrders');\n }\n if (collection === 'projects' && !item.number) {\n item.number = this.getNextNumber('PRJ-', 'projects');\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Rare path: an invoice created already past Draft marks its jobs immediately\n if (collection === 'invoices' && item.status && item.status !== 'Draft' && item.status !== 'Void') {\n this._markJobsInvoiced(item);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Local Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (this.folderSyncEnabled) {\n if (collection === 'documents' && item.url && item.url.startsWith('data:')) {\n this.writeDocumentFileToFolder(item.id, item.name, item.url).then(relPath => {\n if (relPath) {\n item.url = relPath;\n // Re-save in IndexedDB and rewrite folder collection with updated path\n this.writeRecordToIndexedDB(collection, item);\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n });\n } else {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n }\n this.writeRecordToIndexedDB(collection, item).catch(err => {\n console.error(`Error saving ${collection} to IndexedDB:`, err);\n });\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n item.email = email;\n\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) {\n // Roll back the optimistic add so the UI matches the database\n this.cache[collection] = (this.cache[collection] || []).filter(x => x.id !== item.id);\n this.emit(collection, this.cache[collection]);\n this._notifyWriteError('save', collection, error);\n }\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n // Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n this.writeRecordToIndexedDB(collection, updated).catch(err => {\n console.error(`Error saving updated ${collection} to IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const isAuthUpdate = updates.username !== undefined ||\n updates.email !== undefined ||\n updates.password !== undefined ||\n updates.name !== undefined ||\n updates.role !== undefined ||\n updates.userTypeId !== undefined ||\n updates.color !== undefined ||\n updates.payRate !== undefined;\n\n if (isAuthUpdate) {\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to update user.');\n }\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n const dbPayload = this.denormalizeRecord(updated, 'technicians');\n const { error: dbError } = await supabase\n .from('profiles')\n .update(dbPayload)\n .eq('id', id);\n\n if (dbError) {\n throw dbError;\n }\n\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 2b. Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Roll back to the previous version so the UI matches the database\n const arr = [...(this.cache[collection] || [])];\n const i = arr.findIndex(x => x.id === id);\n if (i !== -1) { arr[i] = previous; this.cache[collection] = arr; this.emit(collection, arr); }\n this._notifyWriteError('update', collection, error);\n }\n });\n }\n\n return updated;\n }\n\n // Child records that must not outlive their parent. Deleting a parent cascades to\n // these (scheduling/alert artifacts). Financial/labor records (invoices, timesheets)\n // are deliberately KEPT — they're history and carry their own denormalised numbers.\n _cascadeDelete(collection, id) {\n const CASCADES = {\n jobs: [ ['schedule', 'jobId'], ['formInstances', 'jobId'], ['notifications', 'jobId'] ],\n assets: [ ['maintenancePlans', 'assetId'], ['notifications', 'assetId'] ],\n formTemplates: [ ['formInstances', 'templateId'] ],\n };\n (CASCADES[collection] || []).forEach(([childCol, fk]) => {\n (this.cache[childCol] || []).filter(c => c[fk] === id).forEach(c => this.delete(childCol, c.id));\n });\n }\n\n delete(collection, id) {\n // 0. Cascade-remove dependent child records BEFORE the parent leaves the cache\n this._cascadeDelete(collection, id);\n\n // 1. Update memory cache (keep a copy of the removed row for rollback)\n const removed = (this.cache[collection] || []).find(item => item.id === id);\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Deleting an invoice frees any jobs it (alone) had marked \"Invoiced\"\n if (collection === 'invoices' && removed) {\n this._revertJobsOnInvoiceDelete(removed);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n this.deleteRecordFromIndexedDB(collection, id).catch(err => {\n console.error(`Error deleting ${id} from ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Restore the row we optimistically removed\n if (removed) { this.cache[collection] = [...(this.cache[collection] || []), removed]; this.emit(collection, this.cache[collection]); }\n this._notifyWriteError('delete', collection, error);\n }\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n /**\n * Generate the next sequential number for a collection.\n * Scans existing records, finds the highest numeric suffix, and returns prefix + (max+1) zero-padded to 5 digits.\n * e.g. getNextNumber('INV-', 'invoices') → 'INV-00001', 'INV-00002', ...\n */\n getNextNumber(defaultPrefix, collection) {\n const settings = this.getSettings();\n const dt = settings.documentTheme || {};\n \n let prefix = defaultPrefix;\n let startingNum = 1;\n\n if (collection === 'invoices') {\n prefix = dt.invoicePrefix !== undefined ? dt.invoicePrefix : 'INV-';\n startingNum = dt.invoiceStartingNumber !== undefined ? parseInt(dt.invoiceStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n } else if (collection === 'quotes') {\n prefix = dt.quotePrefix !== undefined ? dt.quotePrefix : 'Q-';\n startingNum = dt.quoteStartingNumber !== undefined ? parseInt(dt.quoteStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n }\n\n const items = this.getAll(collection) || [];\n let maxNum = startingNum - 1;\n\n items.forEach(item => {\n if (item.number && typeof item.number === 'string' && item.number.startsWith(prefix)) {\n const numStr = item.number.slice(prefix.length);\n const num = parseInt(numStr, 10);\n if (!isNaN(num) && num > maxNum) {\n maxNum = num;\n }\n }\n });\n\n return prefix + (maxNum + 1).toString().padStart(5, '0');\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: defaultLogoLarge,\n logoSmall: defaultLogoSmall,\n markupPercent: 20,\n taxEnabled: true,\n taxRate: 10,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n jobTypes: ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'],\n supplierCategories: ['Electrical', 'Plumbing', 'HVAC', 'Fire Safety', 'Security', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n },\n ai: {\n enabled: (this.companyId && !this.companyId.startsWith('acct_')) ? !!(typeof import.meta.env !== 'undefined' && (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY)) : false,\n apiKey: (this.companyId && !this.companyId.startsWith('acct_')) ? (typeof import.meta.env !== 'undefined' ? (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY || '') : '') : '',\n endpoint: 'https://api.openai.com/v1/chat/completions',\n model: 'gpt-4o-mini',\n systemPrompt: 'You are Relay, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default logos if user has not uploaded custom ones\n if (!merged.logo || merged.logo.includes('logo-large.png')) merged.logo = defaultLogoLarge;\n if (!merged.logoSmall) merged.logoSmall = defaultLogoSmall;\n if (!merged.jobTypes) merged.jobTypes = [...defaultSettings.jobTypes];\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n merged.ai = { ...defaultSettings.ai, ...this.companySettings.ai };\n return merged;\n }\n\n return defaultSettings;\n }\n\n getTaxRate() {\n const settings = this.getSettings();\n if (settings.taxEnabled === false) return 0;\n const rate = settings.taxRate !== undefined ? Number(settings.taxRate) : 10;\n return rate / 100;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('settings'), JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── v1.3 Maps: per-user dispatch start location ────────────────────────────\n // Writes profiles.start_location directly (the technicians update() path goes\n // through the invite-user edge function, which doesn't carry this field).\n // Pass null to clear the override (falls back to the company office).\n async setStartLocation(startLocation) {\n const currentUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null') : null;\n if (!currentUser?.id) throw new Error('Not signed in');\n\n // Update cache first so getStartLocation() sees it immediately\n const techs = [...(this.cache.technicians || [])];\n const i = techs.findIndex(t => t.id === currentUser.id);\n if (i !== -1) {\n techs[i] = { ...techs[i], startLocation };\n this.cache.technicians = techs;\n this.emit('technicians', techs);\n }\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n // Local mode: persist with the rest of the technician record\n await this.writeRecordToIndexedDB?.('technicians', techs[i]).catch?.(() => {});\n return;\n }\n const { error } = await supabase\n .from('profiles')\n .update({ start_location: startLocation })\n .eq('id', currentUser.id);\n if (error) {\n this._notifyWriteError('update', 'start location', error);\n throw error;\n }\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId && !this.companyId.startsWith('acct_')) return true;\n return localStorage.getItem(this.getStorageKey('seeded')) === 'true';\n }\n\n save(collection, items) {\n const prev = this.cache[collection] || [];\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const p = this.writeAllToIndexedDB(collection, items).catch(err => {\n console.error(`Error saving all items to ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, items);\n }\n return p;\n }\n\n // Cloud mode: persist the whole collection. Upsert every current row, then delete\n // any rows that were removed from the array. (Previously this only touched the\n // in-memory cache, so every save() — assign-tech, role edits, form templates,\n // portal edits, stock receiving, mark-read, etc. — silently failed to persist.)\n const table = TABLE_MAP[collection];\n if (!table) return; // cache-only collections (e.g. 'activity') have no table\n\n const payload = items\n .filter(it => it && it.id)\n .map(it => this.denormalizeRecord({ ...it, companyId: this.companyId }, collection));\n if (payload.length) {\n supabase.from(table).upsert(payload).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n\n const currentIds = new Set(items.map(it => it && it.id));\n const removedIds = prev.filter(p => p && p.id && !currentIds.has(p.id)).map(p => p.id);\n if (removedIds.length) {\n supabase.from(table).delete().in('id', removedIds).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n }\n\n markSeeded() {\n localStorage.setItem(this.getStorageKey('seeded'), 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('formTemplates', templates);\n return;\n }\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n async seedDefaultUserTypes() {\n if (!this.companyId) return;\n\n const userTypes = [\n {\n id: `${this.companyId}_ut_admin`,\n name: 'Admin',\n description: 'Full system access',\n permissions: buildGranularPerms(() => true)\n },\n {\n id: `${this.companyId}_ut_manager`,\n name: 'Manager',\n description: 'Can manage most workflows but limited settings access',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company', 'manage_tax'].includes(key);\n return true;\n })\n },\n {\n id: `${this.companyId}_ut_tech`,\n name: 'Technician',\n description: 'Field staff — limited to their own jobs, schedule and timesheets',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n {\n id: `${this.companyId}_ut_office`,\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders', 'Suppliers'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n\n this.cache.userTypes = userTypes;\n this.emit('userTypes', userTypes);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('userTypes', userTypes);\n return;\n }\n\n const dbPayload = userTypes.map(ut => this.denormalizeRecord({ ...ut, companyId: this.companyId }, 'userTypes'));\n const { error } = await supabase.from('user_types').insert(dbPayload);\n if (error) console.error('Error seeding default user types:', error);\n }\n\n async seedDefaultTechnicians() {\n const companyId = this.companyId;\n if (!companyId) return;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const managerTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_manager` : 'ut_manager';\n const techTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_tech` : 'ut_tech';\n const officeTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_office` : 'ut_office';\n\n const defaultTechs = [\n { id: `${companyId}_tech_1`, name: 'Jake Morrow', role: 'Senior Electrician', color: '#3B82F6', userTypeId: adminTypeId, payRate: 95.00, email: 'jake@apexpowerservices.com.au', phone: '0412 233 445', username: 'jake', password: '123456' },\n { id: `${companyId}_tech_2`, name: 'Ryan Holt', role: 'Service Manager', color: '#10B981', userTypeId: managerTypeId, payRate: 85.00, email: 'ryan@apexpowerservices.com.au', phone: '0423 344 556', username: 'ryan', password: '123456' },\n { id: `${companyId}_tech_3`, name: 'Sandra Okafor', role: 'Electrician', color: '#8B5CF6', userTypeId: techTypeId, payRate: 80.00, email: 'sandra@apexpowerservices.com.au', phone: '0434 455 667', username: 'sandra', password: '123456' },\n { id: `${companyId}_tech_4`, name: 'Dean Caruso', role: 'Office Administrator',color: '#F59E0B', userTypeId: officeTypeId, payRate: 50.00, email: 'dean@apexpowerservices.com.au', phone: '0445 566 778', username: 'dean', password: '123456' }\n ];\n\n this.cache.technicians = defaultTechs;\n this.emit('technicians', defaultTechs);\n\n if (companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('technicians', defaultTechs);\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder('technicians', defaultTechs).catch(err => {\n console.error('Error writing seeded technicians to local folder:', err);\n });\n }\n return;\n }\n\n const dbPayload = defaultTechs.map(t => this.denormalizeRecord({ ...t, companyId }, 'technicians'));\n const { error } = await supabase.from('technicians').insert(dbPayload);\n if (error) console.error('Error seeding default technicians:', error);\n }\n\n async migrateLocalToCloud(companyId, adminUserId) {\n this.companyId = companyId;\n this.userId = adminUserId;\n\n // 1. Update the remote company settings with local companySettings\n const currentSettings = this.getSettings();\n const { error: compErr } = await supabase.from('companies').update({\n abn: currentSettings.abn || '',\n phone: currentSettings.phone || '',\n domain: currentSettings.domain || '',\n email: currentSettings.email || '',\n address: currentSettings.address || '',\n settings: currentSettings\n }).eq('id', companyId);\n if (compErr) console.error('Error updating cloud company settings during migration:', compErr);\n\n // 2. Loop through all collections and push to Supabase\n // Skip 'companies' and 'technicians'\n const collectionsToMigrate = Object.keys(TABLE_MAP).filter(col => col !== 'companies' && col !== 'technicians');\n\n for (const col of collectionsToMigrate) {\n const items = this.cache[col] || [];\n if (items.length === 0) continue;\n\n const table = TABLE_MAP[col];\n if (!table) continue;\n\n // Assign the new cloud company_id and denormalize\n const payload = items.map(item => {\n const itemCopy = { ...item, companyId };\n return this.denormalizeRecord(itemCopy, col);\n });\n\n // Write to Supabase using upsert\n const { error } = await supabase.from(table).upsert(payload);\n if (error) {\n console.error(`Error migrating collection ${col}:`, error);\n throw new Error(`Failed to migrate ${col} data: ${error.message}`);\n }\n }\n\n // 3. Clear/close local DB connection and sync from Cloud\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n }\n\n deleteLocalAccountData(accountId) {\n return new Promise((resolve) => {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n if (typeof localStorage !== 'undefined') {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem(`relay_${accountId}_${col}`);\n });\n localStorage.removeItem(`relay_${accountId}_folder_sync_enabled`);\n localStorage.removeItem(`relay_${accountId}_seeded`);\n localStorage.removeItem(`relay_${accountId}_settings`);\n }\n if (typeof window !== 'undefined' && window.indexedDB) {\n const req = window.indexedDB.deleteDatabase(`RelayDispatchDB_${accountId}`);\n req.onsuccess = () => resolve();\n req.onerror = () => resolve();\n req.onblocked = () => {\n console.warn('Database deletion blocked, resolving anyway');\n resolve();\n };\n } else {\n resolve();\n }\n });\n }\n\n async clearAll() {\n this.clearSync();\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n \n if (activeAccount) {\n await this.deleteLocalAccountData(activeAccount);\n } else {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n this.dirHandle = null;\n try {\n await this.clearAllIndexedDB();\n } catch (err) {\n console.error('Error clearing IndexedDB:', err);\n }\n }\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "9cf068f395dcb40acd7a638b0a0a28d9c0bbd61d": {"t": 1785754174.0712304, "n": 116111, "h": "3c2442d3ff0e4275", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\nconst defaultLogoLarge = new URL('../assets/RELAY_Dispatch_Logo.png', import.meta.url).href;\nconst defaultLogoSmall = new URL('../assets/logo-small.png', import.meta.url).href;\n\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n passwordResetRequests: 'password_reset_requests',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents',\n leads: 'leads',\n schedule: 'schedule',\n projects: 'projects',\n costCenters: 'cost_centers',\n deputyAsks: null\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"deactivated\",\n \"deactivated_at\",\n \"start_location\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"project_id\",\n \"cost_center_id\",\n \"created_at\",\n \"updated_at\"\n ],\n projects: [\n \"id\",\n \"company_id\",\n \"number\",\n \"name\",\n \"customer_id\",\n \"customer_name\",\n \"site_address\",\n \"status\",\n \"description\",\n \"start_date\",\n \"end_date\",\n \"created_at\",\n \"updated_at\"\n ],\n costCenters: [\n \"id\",\n \"company_id\",\n \"name\",\n \"code\",\n \"active\",\n \"xero_sales_account_code\",\n \"xero_expense_account_code\",\n \"xero_tracking_category_name\",\n \"xero_tracking_option_name\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"paid_date\",\n \"payment_method\",\n \"stripe_session_id\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"license_number\",\n \"hourly_rate\",\n \"after_hours_rate\",\n \"callout_fee\",\n \"specialties\",\n \"notes\",\n \"portal_token\",\n \"compliance_docs\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"address\",\n \"category\",\n \"account_number\",\n \"payment_terms\",\n \"notes\",\n \"attachments\",\n \"created_at\",\n \"updated_at\"\n ],\n leads: [\n \"id\",\n \"company_id\",\n \"number\",\n \"title\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"status\",\n \"source\",\n \"value\",\n \"description\",\n \"priority\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"created_at\",\n \"updated_at\"\n ],\n password_reset_requests: [\n \"id\",\n \"technician_id\",\n \"employee_id\",\n \"requested_at\",\n \"status\",\n \"token\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"type\",\n \"description\",\n \"priority\",\n \"read\",\n \"number\",\n \"asset_id\",\n \"job_id\",\n \"due_date\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"type\",\n \"job_id\",\n \"job_number\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"start_time\",\n \"finish_time\",\n \"hours\",\n \"start_hour\",\n \"end_hour\",\n \"notes\",\n \"status\",\n \"task_id\",\n \"task_name\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g><text x=\"90\" y=\"36\" fill=\"#2D3134\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"25\" font-weight=\"900\" letter-spacing=\"0.04em\">DISPATCH</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nconst MODULE_PERMS = {\n 'Dashboard': [{ key: 'view' }],\n 'Customers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_contacts' }],\n 'Leads': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'convert' }],\n 'Quotes': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'approve' }, { key: 'convert' }, { key: 'generate_pdf' }],\n 'Jobs': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_tasks' }, { key: 'book_time' }, { key: 'view_costs' }, { key: 'view_quotes_tab' }, { key: 'view_pos_tab' }, { key: 'view_timesheets_tab' }, { key: 'view_invoices_tab' }, { key: 'manage_materials' }, { key: 'create_invoice' }],\n 'Timesheets': [{ key: 'view_own' }, { key: 'view' }, { key: 'create' }, { key: 'approve' }, { key: 'edit_all' }, { key: 'export' }],\n 'Assets': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Schedule': [{ key: 'view_own' }, { key: 'view' }, { key: 'edit' }],\n 'Contractors': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }],\n 'Suppliers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Stock': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Purchase Orders': [{ key: 'view' }, { key: 'create' }, { key: 'approve' }],\n 'Invoices': [{ key: 'view' }, { key: 'create' }, { key: 'send' }, { key: 'void' }],\n 'Reports': [{ key: 'view' }, { key: 'export' }],\n 'Documents': [{ key: 'view' }, { key: 'upload' }],\n 'Settings': [{ key: 'view' }, { key: 'edit_company' }, { key: 'manage_users' }, { key: 'manage_tax' }]\n};\n\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n this.db = null;\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n this.backupDirHandle = null;\n this.backupDirPermissionGranted = false;\n\n // Check if we have a cloud user at boot (before filling cache)\n const bootUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n const isCloudUser = !!(bootUser && bootUser.companyId && !bootUser.companyId.startsWith('acct_'));\n\n if (!isCloudUser && bootUser && bootUser.companyId && typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', bootUser.companyId);\n }\n\n // Pre-initialize empty arrays for collections.\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Load local settings if they exist (only for offline/demo mode)\n if (!isCloudUser && typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId && !this.companyId.startsWith('acct_')) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n // Only clear sync if we were previously in cloud sync mode or companyId is not defined\n if (!this.companyId || !this.companyId.startsWith('acct_')) {\n this.clearSync();\n }\n }\n });\n\n // Auto-trigger sync or local load if user already logged in at boot\n if (isCloudUser) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n this.initPromise = this.initializeCloudSync();\n } else {\n if (bootUser && bootUser.companyId) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n }\n this.initPromise = this.initializeLocalStore();\n }\n }\n\n getStorageKey(collection) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (activeAccount) {\n return `relay_${activeAccount}_${collection}`;\n }\n return `simpro_${collection}`;\n }\n\n getDBName() {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n return activeAccount ? `RelayDispatchDB_${activeAccount}` : 'RelayDispatchDB';\n }\n\n async initializeUser(user) {\n if (user && user.companyId && !user.companyId.startsWith('acct_')) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n } else {\n this.clearSync();\n if (user && user.companyId) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', user.companyId);\n }\n } else {\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n }\n this.initPromise = this.initializeLocalStore();\n await this.initPromise;\n }\n }\n\n async initializeLocalStore() {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n\n try {\n await this.initIndexedDB();\n } catch (e) {\n console.error('Failed to initialize IndexedDB, falling back to empty arrays:', e);\n }\n\n // Load local settings if they exist (namespaced)\n this.companySettings = null;\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n this.emit('settings', this.getSettings());\n\n // Seed default user types and forms if empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n if (this.cache.technicians.length === 0) {\n await this.seedDefaultTechnicians();\n }\n }\n\n initIndexedDB() {\n return new Promise((resolve, reject) => {\n if (typeof window === 'undefined' || !window.indexedDB) {\n return resolve();\n }\n\n const dbName = this.getDBName();\n const request = window.indexedDB.open(dbName, 4);\n\n request.onerror = (e) => {\n console.error('IndexedDB open error:', e.target.error);\n reject(e.target.error);\n };\n\n request.onsuccess = async (e) => {\n this.db = e.target.result;\n \n // 1. Load custom directory sync config from IndexedDB config store if it exists\n try {\n if (this.db.objectStoreNames.contains('config')) {\n await new Promise((resConfig) => {\n const transaction = this.db.transaction('config', 'readonly');\n const configStore = transaction.objectStore('config');\n \n const getSync = new Promise((resolve) => {\n const req = configStore.get('directory_handle');\n req.onsuccess = () => {\n console.log('[DEBUG Store] getSync onsuccess result:', req.result);\n if (req.result) {\n this.dirHandle = req.result.value;\n console.log('[DEBUG Store] loaded dirHandle:', this.dirHandle ? this.dirHandle.name : null);\n }\n resolve();\n };\n req.onerror = (e) => {\n console.error('[DEBUG Store] getSync onerror:', e);\n resolve();\n };\n });\n\n const getBackup = new Promise((resolve) => {\n const req = configStore.get('backup_directory_handle');\n req.onsuccess = () => {\n if (req.result) this.backupDirHandle = req.result.value;\n resolve();\n };\n req.onerror = () => resolve();\n });\n\n Promise.all([getSync, getBackup]).then(() => resConfig());\n });\n }\n } catch (err) {\n console.warn('Failed to read config from IndexedDB:', err);\n }\n\n // Verify directory permission silently on boot if handle exists\n if (this.dirHandle) {\n try {\n // Check permission silently without prompt dialog\n const opts = { mode: 'readwrite' };\n const status = await this.dirHandle.queryPermission(opts);\n this.folderSyncPermissionGranted = status === 'granted';\n } catch (err) {\n console.warn('Failed to query directory permission on boot:', err);\n }\n }\n\n // Verify backup directory permission silently on boot if handle exists\n if (this.backupDirHandle) {\n try {\n const opts = { mode: 'readwrite' };\n const status = await this.backupDirHandle.queryPermission(opts);\n this.backupDirPermissionGranted = status === 'granted';\n\n // Auto-trigger backup if granted and > 7 days since last backup\n if (this.backupDirPermissionGranted) {\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;\n if (!lastBackup || new Date(lastBackup).getTime() < sevenDaysAgo) {\n this.backupToFolder().catch(err => {\n console.error('Auto background backup failed:', err);\n });\n }\n }\n } catch (err) {\n console.warn('Failed to query backup directory permission on boot:', err);\n }\n }\n\n // Load all collections into cache\n const collections = Object.keys(TABLE_MAP);\n const loadPromises = collections.map(async (col) => {\n try {\n this.cache[col] = await this.readAllFromIndexedDB(col);\n } catch (err) {\n console.error(`Error loading collection ${col} from IndexedDB:`, err);\n this.cache[col] = [];\n }\n });\n\n await Promise.all(loadPromises);\n\n // Perform migration if we have old data in localStorage (only if not in a namespaced local account profile)\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (!activeAccount) {\n const migratePromises = collections.map(async (col) => {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n const items = JSON.parse(localData);\n if (Array.isArray(items) && items.length > 0) {\n // Write all items to IndexedDB\n await this.writeAllToIndexedDB(col, items);\n // Merge into cache if cache is empty\n if (this.cache[col].length === 0) {\n this.cache[col] = items;\n }\n }\n localStorage.removeItem('simpro_' + col);\n } catch (err) {\n console.error(`Migration error for ${col}:`, err);\n }\n }\n });\n\n await Promise.all(migratePromises);\n }\n resolve();\n };\n\n request.onupgradeneeded = (e) => {\n const db = e.target.result;\n const collections = Object.keys(TABLE_MAP);\n collections.forEach(col => {\n if (!db.objectStoreNames.contains(col)) {\n db.createObjectStore(col, { keyPath: 'id' });\n }\n });\n if (!db.objectStoreNames.contains('config')) {\n db.createObjectStore('config', { keyPath: 'key' });\n }\n };\n });\n }\n\n readAllFromIndexedDB(storeName) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve([]);\n try {\n const transaction = this.db.transaction(storeName, 'readonly');\n const store = transaction.objectStore(storeName);\n const getAllRequest = store.getAll();\n\n getAllRequest.onsuccess = () => {\n resolve(getAllRequest.result || []);\n };\n\n getAllRequest.onerror = (e) => {\n reject(e.target.error);\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeRecordToIndexedDB(storeName, record) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const putRequest = store.put(record);\n\n putRequest.onsuccess = () => resolve();\n putRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n deleteRecordFromIndexedDB(storeName, id) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const deleteRequest = store.delete(id);\n\n deleteRequest.onsuccess = () => resolve();\n deleteRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeAllToIndexedDB(storeName, items) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n \n // Clear existing\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n \n clearRequest.onsuccess = () => {\n let count = 0;\n if (items.length === 0) return resolve();\n \n items.forEach(item => {\n const putRequest = store.put(item);\n putRequest.onerror = (e) => reject(e.target.error);\n putRequest.onsuccess = () => {\n count++;\n if (count === items.length) {\n resolve();\n }\n };\n });\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n clearAllIndexedDB() {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const collections = Object.keys(TABLE_MAP);\n const transaction = this.db.transaction(collections, 'readwrite');\n let count = 0;\n \n collections.forEach(col => {\n const store = transaction.objectStore(col);\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n clearRequest.onsuccess = () => {\n count++;\n if (count === collections.length) {\n resolve();\n }\n };\n });\n } catch (err) {\n reject(err);\n }\n });\n }\n\n // ── Local Folder Sync / File System Access Driver ──────────────────────────\n\n async verifyDirPermission(readWrite) {\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n this.folderSyncPermissionGranted = true;\n return true; // Capacitor native has permissions checked at app level\n }\n\n if (!this.dirHandle) return false;\n const opts = {};\n if (readWrite) opts.mode = 'readwrite';\n \n try {\n const currentPerm = await this.dirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.folderSyncPermissionGranted = true;\n return true;\n }\n \n // Request permission (must be called within a user gesture handler)\n const newPerm = await this.dirHandle.requestPermission(opts);\n this.folderSyncPermissionGranted = newPerm === 'granted';\n return this.folderSyncPermissionGranted;\n } catch (e) {\n this.folderSyncPermissionGranted = false;\n return false;\n }\n }\n\n async verifyBackupDirPermission(requestInteractive) {\n if (!this.backupDirHandle) return false;\n const opts = { mode: 'readwrite' };\n try {\n const currentPerm = await this.backupDirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.backupDirPermissionGranted = true;\n return true;\n }\n if (requestInteractive) {\n const newPerm = await this.backupDirHandle.requestPermission(opts);\n this.backupDirPermissionGranted = newPerm === 'granted';\n return this.backupDirPermissionGranted;\n }\n this.backupDirPermissionGranted = false;\n return false;\n } catch (e) {\n this.backupDirPermissionGranted = false;\n return false;\n }\n }\n\n // Ensure a subfolder for the current company exists within the selected directory.\n async ensureCompanyFolderHandle(customDirHandle) {\n const baseHandle = customDirHandle || this.dirHandle;\n if (!baseHandle) return null;\n const rawName = this.companySettings?.name || this.companySettings?.id || 'Company';\n const safeName = rawName.replace(/[\\\\/:*?\"<>|]/g, '_');\n try {\n const companyHandle = await baseHandle.getDirectoryHandle(safeName, { create: true });\n return companyHandle;\n } catch (e) {\n console.error('Failed to get/create company folder:', e);\n return null;\n }\n }\n\n async setBackupDirectory(dirHandle) {\n this.backupDirHandle = dirHandle || null;\n this.backupDirPermissionGranted = !!dirHandle;\n \n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n if (dirHandle) {\n const putReq = configStore.put({ key: 'backup_directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = () => resolve();\n } else {\n const deleteReq = configStore.delete('backup_directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n }\n });\n }\n }\n\n async backupToFolder(dirHandle) {\n const handle = dirHandle || this.backupDirHandle;\n if (!handle) throw new Error('No backup directory configured.');\n \n const currentPerm = await handle.queryPermission({ mode: 'readwrite' });\n if (currentPerm !== 'granted') {\n const newPerm = await handle.requestPermission({ mode: 'readwrite' });\n if (newPerm !== 'granted') {\n throw new Error('Write permission denied for backup directory.');\n }\n }\n \n // Ensure company-specific folder exists within the selected directory\n const companyFolderHandle = await this.ensureCompanyFolderHandle(dirHandle);\n const dataDir = await companyFolderHandle.getDirectoryHandle('data', { create: true });\n const collections = Object.keys(TABLE_MAP);\n for (const col of collections) {\n const items = this.cache[col] || [];\n const fileHandle = await dataDir.getFileHandle(`${col}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items, null, 2));\n await writable.close();\n }\n \n // Update last backup timestamp\n localStorage.setItem('relay_last_backup_time', new Date().toISOString());\n this.backupDirPermissionGranted = true;\n }\n\n async setLocalDirectory(dirHandle) {\n this.dirHandle = dirHandle;\n if (dirHandle) {\n this.folderSyncEnabled = true;\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('folder_sync_enabled'), 'true');\n }\n \n // Save handle to IndexedDB\n if (this.db) {\n await new Promise((resolve, reject) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const putReq = configStore.put({ key: 'directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = (e) => reject(e.target.error);\n });\n }\n \n this.folderSyncPermissionGranted = true;\n \n // Force rewrite all cached collections to directory immediately\n const collections = Object.keys(TABLE_MAP);\n await Promise.all(collections.map(col => this.writeCollectionToFolder(col, this.cache[col])));\n } else {\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n \n // Remove handle from IndexedDB\n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const deleteReq = configStore.delete('directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n });\n }\n }\n }\n\n async writeCollectionToFolder(collection, items) {\n if (!this.folderSyncEnabled) return;\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/data/${collection}.json`,\n data: JSON.stringify(items || [], null, 2),\n directory: 'DOCUMENTS',\n encoding: 'utf8',\n recursive: true\n });\n } catch (err) {\n console.error(`Capacitor failed to write collection ${collection}:`, err);\n }\n return;\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return;\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return;\n const dataDir = await companyFolder.getDirectoryHandle('data', { create: true });\n const fileHandle = await dataDir.getFileHandle(`${collection}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items || [], null, 2));\n await writable.close();\n } catch (err) {\n console.error(`Failed to write collection ${collection} to local directory:`, err);\n }\n }\n\n async writeDocumentFileToFolder(docId, name, dataUrl) {\n if (!this.folderSyncEnabled) return null;\n\n const ext = name.includes('.') ? name.split('.').pop() : 'bin';\n const fileName = `${docId}.${ext}`;\n const relativePath = `documents/${fileName}`;\n\n // Extract base64 payload\n const base64Parts = dataUrl.split(',');\n if (base64Parts.length < 2) return null;\n const base64Data = base64Parts[1];\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/documents/${fileName}`,\n data: base64Data,\n directory: 'DOCUMENTS',\n recursive: true\n });\n return relativePath;\n } catch (err) {\n console.error('Capacitor failed to write document:', err);\n return null;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return null;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return null;\n\n const mimeMatch = base64Parts[0].match(/:(.*?);/);\n const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';\n const binaryStr = atob(base64Data);\n const len = binaryStr.length;\n const bytes = new Uint8Array(len);\n for (let i = 0; i < len; i++) {\n bytes[i] = binaryStr.charCodeAt(i);\n }\n const blob = new Blob([bytes], { type: mime });\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return null;\n const docsDir = await companyFolder.getDirectoryHandle('documents', { create: true });\n const fileHandle = await docsDir.getFileHandle(fileName, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(blob);\n await writable.close();\n\n return relativePath;\n } catch (err) {\n console.error('Failed to save document file to local directory:', err);\n return null;\n }\n }\n\n async getDocumentUrl(doc) {\n if (!doc || !doc.url) return '';\n if (doc.url.startsWith('data:') || doc.url.startsWith('http')) {\n return doc.url;\n }\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n const result = await Filesystem.readFile({\n path: `RelayDispatchData/${doc.url}`,\n directory: 'DOCUMENTS'\n });\n const mime = doc.type || 'application/octet-stream';\n return `data:${mime};base64,${result.data}`;\n } catch (err) {\n console.error('Capacitor failed to read document file:', err);\n throw err;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) {\n throw new Error('Local directory sync is not configured.');\n }\n\n try {\n const hasPerm = await this.verifyDirPermission(false);\n if (!hasPerm) {\n throw new Error('Permission to access the local directory was not granted.');\n }\n\n const parts = doc.url.split('/');\n const dirName = parts[0];\n const fileName = parts[1];\n\n const dir = await this.dirHandle.getDirectoryHandle(dirName);\n const fileHandle = await dir.getFileHandle(fileName);\n const file = await fileHandle.getFile();\n return URL.createObjectURL(file);\n } catch (err) {\n console.error('Failed to read file from local directory:', err);\n throw err;\n }\n }\n\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // Initialize IndexedDB for cloud users too, to store backup directory handles\n await this.initIndexedDB();\n\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id\n query = query.eq('company_id', this.companyId);\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n await this.writeAllToIndexedDB(col, this.cache[col]);\n }\n });\n\n await Promise.all(promises);\n\n // Seed default user types if database is empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n // Unique channel name per subscribe — reusing a fixed name throws\n // \"cannot add postgres_changes callbacks ... after subscribe()\" when sync re-inits.\n const channel = supabase\n .channel(`table-db-changes-${this.companyId || 'anon'}-${Date.now()}`)\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n if (collection === 'contractors') {\n record.businessName = record.name;\n record.active = record.status === 'Active';\n }\n\n if (collection === 'suppliers') {\n record.active = record.status === 'Active';\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.project_id !== undefined) {\n record.projectId = record.project_id;\n delete record.project_id;\n }\n if (record.cost_center_id !== undefined) {\n record.costCenterId = record.cost_center_id;\n delete record.cost_center_id;\n }\n if (record.xero_sales_account_code !== undefined) {\n record.xeroSalesAccountCode = record.xero_sales_account_code;\n delete record.xero_sales_account_code;\n }\n if (record.xero_expense_account_code !== undefined) {\n record.xeroExpenseAccountCode = record.xero_expense_account_code;\n delete record.xero_expense_account_code;\n }\n if (record.xero_tracking_category_name !== undefined) {\n record.xeroTrackingCategoryName = record.xero_tracking_category_name;\n delete record.xero_tracking_category_name;\n }\n if (record.xero_tracking_option_name !== undefined) {\n record.xeroTrackingOptionName = record.xero_tracking_option_name;\n delete record.xero_tracking_option_name;\n }\n if (record.deactivated_at !== undefined) {\n record.deactivatedAt = record.deactivated_at;\n delete record.deactivated_at;\n }\n if (record.start_date !== undefined) {\n record.startDate = record.start_date;\n delete record.start_date;\n }\n if (record.end_date !== undefined) {\n record.endDate = record.end_date;\n delete record.end_date;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (collection === 'schedule') {\n if (record.job_id !== undefined) { record.jobId = record.job_id; delete record.job_id; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.technician_id !== undefined) { record.technicianId = record.technician_id; delete record.technician_id; }\n if (record.technician_name !== undefined) { record.technicianName = record.technician_name; delete record.technician_name; }\n if (record.start_time !== undefined) { record.startTime = record.start_time; delete record.start_time; }\n if (record.finish_time !== undefined) { record.finishTime = record.finish_time; delete record.finish_time; }\n if (record.start_hour !== undefined) { record.startHour = record.start_hour != null ? parseFloat(record.start_hour) : null; delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = record.end_hour != null ? parseFloat(record.end_hour) : null; delete record.end_hour; }\n if (record.task_id !== undefined) { record.taskId = record.task_id; delete record.task_id; }\n if (record.task_name !== undefined) { record.taskName = record.task_name; delete record.task_name; }\n }\n\n if (record.line_items !== undefined) {\n if (collection === 'invoices' || collection === 'quotes') {\n const items = record.line_items;\n if (Array.isArray(items) && items.length > 0 && (items[0].lineItems !== undefined || items[0].items !== undefined || (items[0].name !== undefined && items[0].qty === undefined))) {\n record.sections = items;\n } else {\n record.lineItems = items;\n }\n } else {\n record.lineItems = record.line_items;\n }\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.paid_date !== undefined) {\n record.paidDate = record.paid_date;\n delete record.paid_date;\n }\n if (record.payment_method !== undefined) {\n record.paymentMethod = record.payment_method;\n delete record.payment_method;\n }\n if (record.stripe_session_id !== undefined) {\n record.stripeSessionId = record.stripe_session_id;\n delete record.stripe_session_id;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined || record.durationHours !== undefined || record.hours !== undefined) {\n const h = parseFloat(record.duration_hours !== undefined ? record.duration_hours : (record.durationHours !== undefined ? record.durationHours : record.hours)) || 0;\n record.durationHours = h;\n record.hours = h;\n delete record.duration_hours;\n }\n if (record.task_name !== undefined) { record.taskName = record.task_name; delete record.task_name; }\n if (record.task_id !== undefined) { record.taskId = record.task_id; delete record.task_id; }\n if (record.task_path !== undefined) { record.taskPath = record.task_path; delete record.task_path; }\n if (record.phase_name !== undefined) { record.phaseName = record.phase_name; delete record.phase_name; }\n if (record.phase_id !== undefined) { record.phaseId = record.phase_id; delete record.phase_id; }\n if (record.start_time !== undefined) { record.startTime = record.start_time; delete record.start_time; }\n if (record.finish_time !== undefined) { record.finishTime = record.finish_time; delete record.finish_time; }\n if (record.approved_by && typeof record.approved_by === 'string' && record.approved_by.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.approved_by.substring(9));\n if (parsed.taskId !== undefined) record.taskId = parsed.taskId;\n if (parsed.taskName !== undefined) record.taskName = parsed.taskName;\n if (parsed.taskPath !== undefined) record.taskPath = parsed.taskPath;\n if (parsed.phaseId !== undefined) record.phaseId = parsed.phaseId;\n if (parsed.phaseName !== undefined) record.phaseName = parsed.phaseName;\n if (parsed.startTime !== undefined) record.startTime = parsed.startTime;\n if (parsed.finishTime !== undefined) record.finishTime = parsed.finishTime;\n if (parsed.description !== undefined) record.description = parsed.description;\n if (parsed.jobNumber !== undefined) record.jobNumber = parsed.jobNumber;\n if (parsed.jobTitle !== undefined) record.jobTitle = parsed.jobTitle;\n if (parsed.hours !== undefined) {\n record.hours = parsed.hours;\n record.durationHours = parsed.hours;\n }\n record.approvedBy = parsed.approvedBy || null;\n } catch (e) {\n console.error(e);\n }\n delete record.approved_by;\n } else if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.license_number !== undefined) { record.licenseNumber = record.license_number; delete record.license_number; }\n if (record.hourly_rate !== undefined) { record.hourlyRate = parseFloat(record.hourly_rate); delete record.hourly_rate; }\n if (record.after_hours_rate !== undefined) { record.afterHoursRate = parseFloat(record.after_hours_rate); delete record.after_hours_rate; }\n if (record.callout_fee !== undefined) { record.calloutFee = parseFloat(record.callout_fee); delete record.callout_fee; }\n if (record.compliance_docs !== undefined) { record.complianceDocs = record.compliance_docs; delete record.compliance_docs; }\n if (record.account_number !== undefined) { record.accountNumber = record.account_number; delete record.account_number; }\n if (record.payment_terms !== undefined) { record.paymentTerms = record.payment_terms; delete record.payment_terms; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.start_location !== undefined) { record.startLocation = record.start_location; delete record.start_location; }\n if (record.day_offset !== undefined) { record.dayOffset = parseInt(record.day_offset); delete record.day_offset; }\n if (record.start_hour !== undefined) { record.startHour = parseFloat(record.start_hour); delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = parseFloat(record.end_hour); delete record.end_hour; }\n\n if (record.color && record.color.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.color.substring(9));\n record.taskId = parsed.taskId;\n record.taskPath = parsed.taskId;\n record.taskName = parsed.taskName;\n record.color = parsed.color;\n if (parsed.date !== undefined) record.date = parsed.date;\n if (parsed.startTime !== undefined) record.startTime = parsed.startTime;\n if (parsed.finishTime !== undefined) record.finishTime = parsed.finishTime;\n if (parsed.hours !== undefined) record.hours = parsed.hours;\n if (parsed.startHour !== undefined && parsed.startHour !== null) record.startHour = parsed.startHour;\n if (parsed.endHour !== undefined && parsed.endHour !== null) record.endHour = parsed.endHour;\n if (parsed.dayOffset !== undefined && parsed.dayOffset !== null) record.dayOffset = parsed.dayOffset;\n } catch (e) {\n console.error(e);\n }\n }\n if (collection === 'invoices') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.invoiceType = 'Standard';\n record.laborProfileId = '';\n record.issueDate = '';\n record.originalQuoteId = '';\n record.originalQuoteNumber = '';\n record.originalSubtotal = 0;\n record.approvedVariationsSum = 0;\n record.pendingVariationsSum = 0;\n record.totalInternalCost = 0;\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.invoiceType = meta.invoiceType || 'Standard';\n record.laborProfileId = meta.laborProfileId || '';\n record.issueDate = meta.issueDate || '';\n record.originalQuoteId = meta.originalQuoteId || '';\n record.originalQuoteNumber = meta.originalQuoteNumber || '';\n record.originalSubtotal = meta.originalSubtotal || 0;\n record.approvedVariationsSum = meta.approvedVariationsSum || 0;\n record.pendingVariationsSum = meta.pendingVariationsSum || 0;\n record.totalInternalCost = meta.totalInternalCost || 0;\n record.jobIds = meta.jobIds || []; // consolidated invoices link multiple jobs\n }\n }\n if (collection === 'quotes') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.laborProfileId = '';\n record.description = '';\n record.isTemplate = false;\n record.templateId = '';\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.laborProfileId = meta.laborProfileId || '';\n record.description = meta.description || '';\n record.isTemplate = meta.isTemplate || false;\n record.templateId = meta.templateId || '';\n }\n }\n if (collection === 'jobs') {\n if (record.notes && record.notes.startsWith('__meta__:')) {\n try {\n const meta = JSON.parse(record.notes.substring(9));\n record.isRecurring = meta.isRecurring || false;\n record.recurringConfig = meta.recurringConfig || null;\n record.preferredTime = meta.preferredTime || '';\n record.description = meta.description || '';\n record.parentJobId = meta.parentJobId || null;\n record.assetName = meta.assetName || '';\n record.notes = meta.notes || '';\n } catch (e) {\n console.error('Error parsing jobs meta:', e);\n }\n }\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (collection === 'contractors') {\n record.name = record.businessName;\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (collection === 'suppliers') {\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.xeroSalesAccountCode !== undefined) {\n record.xero_sales_account_code = record.xeroSalesAccountCode;\n delete record.xeroSalesAccountCode;\n }\n if (record.xeroExpenseAccountCode !== undefined) {\n record.xero_expense_account_code = record.xeroExpenseAccountCode;\n delete record.xeroExpenseAccountCode;\n }\n if (record.xeroTrackingCategoryName !== undefined) {\n record.xero_tracking_category_name = record.xeroTrackingCategoryName;\n delete record.xeroTrackingCategoryName;\n }\n if (record.xeroTrackingOptionName !== undefined) {\n record.xero_tracking_option_name = record.xeroTrackingOptionName;\n delete record.xeroTrackingOptionName;\n }\n if (record.deactivatedAt !== undefined) {\n record.deactivated_at = record.deactivatedAt;\n delete record.deactivatedAt;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.projectId !== undefined) {\n record.project_id = record.projectId;\n delete record.projectId;\n }\n if (record.costCenterId !== undefined) {\n record.cost_center_id = record.costCenterId;\n delete record.costCenterId;\n }\n if (record.startDate !== undefined) {\n record.start_date = record.startDate;\n delete record.startDate;\n }\n if (record.endDate !== undefined) {\n record.end_date = record.endDate;\n delete record.endDate;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.sections !== undefined && (collection === 'invoices' || collection === 'quotes')) {\n record.line_items = record.sections;\n delete record.sections;\n } else if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.paidDate !== undefined) {\n record.paid_date = record.paidDate;\n delete record.paidDate;\n }\n if (record.paymentMethod !== undefined) {\n record.payment_method = record.paymentMethod;\n delete record.paymentMethod;\n }\n if (record.stripeSessionId !== undefined) {\n record.stripe_session_id = record.stripeSessionId;\n delete record.stripeSessionId;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined || record.hours !== undefined) {\n const h = parseFloat(record.durationHours !== undefined ? record.durationHours : record.hours) || 0;\n record.duration_hours = h;\n record.durationHours = h;\n record.hours = h;\n }\n if (record.taskName !== undefined) record.task_name = record.taskName;\n if (record.taskId !== undefined) record.task_id = record.taskId;\n if (record.taskPath !== undefined) record.task_path = record.taskPath;\n if (record.phaseName !== undefined) record.phase_name = record.phaseName;\n if (record.phaseId !== undefined) record.phase_id = record.phaseId;\n if (record.startTime !== undefined) record.start_time = record.startTime;\n if (record.finishTime !== undefined) record.finish_time = record.finishTime;\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.licenseNumber !== undefined) { record.license_number = record.licenseNumber; delete record.licenseNumber; }\n if (record.hourlyRate !== undefined) { record.hourly_rate = record.hourlyRate; delete record.hourlyRate; }\n if (record.afterHoursRate !== undefined) { record.after_hours_rate = record.afterHoursRate; delete record.afterHoursRate; }\n if (record.calloutFee !== undefined) { record.callout_fee = record.calloutFee; delete record.calloutFee; }\n if (record.complianceDocs !== undefined) { record.compliance_docs = record.complianceDocs; delete record.complianceDocs; }\n if (record.accountNumber !== undefined) { record.account_number = record.accountNumber; delete record.accountNumber; }\n if (record.paymentTerms !== undefined) { record.payment_terms = record.paymentTerms; delete record.paymentTerms; }\n if (record.jobNumber !== undefined) { record.job_number = record.jobNumber; delete record.jobNumber; }\n if (record.startLocation !== undefined) { record.start_location = record.startLocation; delete record.startLocation; }\n if (record.dayOffset !== undefined) { record.day_offset = record.dayOffset; delete record.dayOffset; }\n if (record.startHour !== undefined) { record.start_hour = record.startHour; delete record.startHour; }\n if (record.endHour !== undefined) { record.end_hour = record.endHour; delete record.endHour; }\n\n if (collection === 'schedule') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || null,\n color: record.color || null,\n date: record.date || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n hours: record.hours || null,\n startHour: record.startHour !== undefined ? record.startHour : null,\n endHour: record.endHour !== undefined ? record.endHour : null,\n dayOffset: record.dayOffset !== undefined ? record.dayOffset : null\n };\n record.color = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'jobs') {\n const meta = {\n isRecurring: record.isRecurring || false,\n recurringConfig: record.recurringConfig || null,\n preferredTime: record.preferredTime || '',\n description: record.description || '',\n parentJobId: record.parentJobId || null,\n assetName: record.assetName || '',\n notes: record.notes || ''\n };\n record.notes = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'invoices') {\n const meta = {\n sections: record.sections || [],\n invoiceType: record.invoiceType || 'Standard',\n laborProfileId: record.laborProfileId || '',\n issueDate: record.issueDate || '',\n originalQuoteId: record.originalQuoteId || '',\n originalQuoteNumber: record.originalQuoteNumber || '',\n originalSubtotal: record.originalSubtotal || 0,\n approvedVariationsSum: record.approvedVariationsSum || 0,\n pendingVariationsSum: record.pendingVariationsSum || 0,\n totalInternalCost: record.totalInternalCost || 0,\n jobIds: record.jobIds || [] // consolidated invoices link multiple jobs\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n if (collection === 'quotes') {\n const meta = {\n sections: record.sections || [],\n laborProfileId: record.laborProfileId || '',\n description: record.description || '',\n isTemplate: record.isTemplate || false,\n templateId: record.templateId || ''\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n if (collection === 'timesheets') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || record.phaseName || null,\n taskPath: record.taskPath || null,\n phaseId: record.phaseId || null,\n phaseName: record.phaseName || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n description: record.description || null,\n hours: record.hours !== undefined ? record.hours : record.duration_hours,\n approvedBy: record.approvedBy || null,\n jobNumber: record.jobNumber || null,\n jobTitle: record.jobTitle || null\n };\n record.approved_by = '__meta__:' + JSON.stringify(meta);\n delete record.approvedBy;\n }\n\n if (collection === 'schedule') {\n record.job_id = record.jobId || null; delete record.jobId;\n record.job_number = record.jobNumber || null; delete record.jobNumber;\n record.technician_id = record.technicianId || null; delete record.technicianId;\n record.technician_name = record.technicianName || null; delete record.technicianName;\n record.start_time = record.startTime || null; delete record.startTime;\n record.finish_time = record.finishTime || null; delete record.finishTime;\n record.start_hour = record.startHour !== undefined ? record.startHour : null; delete record.startHour;\n record.end_hour = record.endHour !== undefined ? record.endHour : null; delete record.endHour;\n record.task_id = record.taskId || null; delete record.taskId;\n record.task_name = record.taskName || null; delete record.taskName;\n }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n const items = this.cache[collection] || [];\n if (collection === 'technicians') {\n const loginMode = typeof localStorage !== 'undefined' ? localStorage.getItem('relay_login_mode') : null;\n if (loginMode === 'local') {\n const currentUserStr = typeof localStorage !== 'undefined' ? localStorage.getItem('currentUser') : null;\n if (currentUserStr) {\n const currentUser = JSON.parse(currentUserStr);\n if (currentUser && currentUser.id) {\n return [{\n id: currentUser.id,\n name: currentUser.name || 'Local Admin',\n role: 'Administrator',\n color: currentUser.color || '#FF5C00',\n userTypeId: currentUser.userTypeId,\n email: currentUser.email || '',\n username: currentUser.username || ''\n }];\n }\n }\n }\n }\n return items;\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Surface a failed background write to the user instead of failing silently.\n // (A silent insert/update failure leaves the optimistic cache entry in place, so the\n // record looks saved until the next reload — the classic \"it didn't save\" symptom.)\n _notifyWriteError(action, collection, error) {\n console.error(`Error trying to ${action} ${collection}:`, error?.message || error, error);\n try {\n import('../components/Notifications.js')\n .then(({ showToast }) => showToast(\n `Couldn't ${action} ${collection.replace(/s$/, '')} — ${error?.message || 'database error'}`,\n 'error'))\n .catch(() => {});\n } catch (e) {}\n }\n\n // All jobs an invoice bills: single jobId and/or consolidated jobIds\n _invoiceJobIds(inv) {\n return [inv?.jobId, ...(inv?.jobIds || [])].filter(Boolean);\n }\n\n // Invoice lifecycle → job status sync. Jobs are only marked \"Invoiced\" when their\n // invoice actually leaves Draft (Sent/Paid/etc.) — NOT when a draft is created, so\n // abandoning/deleting a draft never strands jobs in the wrong status.\n _markJobsInvoiced(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (job && job.status !== 'Invoiced') this.update('jobs', jid, { status: 'Invoiced' });\n });\n }\n\n // When an invoice is deleted, revert any of its jobs still marked \"Invoiced\" —\n // unless another invoice also bills that job.\n _revertJobsOnInvoiceDelete(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (!job || job.status !== 'Invoiced') return;\n const billedElsewhere = (this.cache.invoices || []).some(other =>\n other.id !== inv.id && this._invoiceJobIds(other).includes(jid));\n if (!billedElsewhere) this.update('jobs', jid, { status: 'Completed' });\n });\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = this.getNextNumber('LD-', 'leads');\n }\n if (collection === 'notifications' && !item.number) {\n item.number = this.getNextNumber('NT-', 'notifications');\n }\n if (collection === 'invoices' && !item.number) {\n item.number = this.getNextNumber('INV-', 'invoices');\n }\n if (collection === 'quotes' && !item.number) {\n item.number = this.getNextNumber('Q-', 'quotes');\n }\n if (collection === 'jobs' && !item.number) {\n item.number = this.getNextNumber('J-', 'jobs');\n }\n if (collection === 'purchaseOrders' && !item.number) {\n item.number = this.getNextNumber('PO-', 'purchaseOrders');\n }\n if (collection === 'projects' && !item.number) {\n item.number = this.getNextNumber('PRJ-', 'projects');\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Rare path: an invoice created already past Draft marks its jobs immediately\n if (collection === 'invoices' && item.status && item.status !== 'Draft' && item.status !== 'Void') {\n this._markJobsInvoiced(item);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Local Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (this.folderSyncEnabled) {\n if (collection === 'documents' && item.url && item.url.startsWith('data:')) {\n this.writeDocumentFileToFolder(item.id, item.name, item.url).then(relPath => {\n if (relPath) {\n item.url = relPath;\n // Re-save in IndexedDB and rewrite folder collection with updated path\n this.writeRecordToIndexedDB(collection, item);\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n });\n } else {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n }\n this.writeRecordToIndexedDB(collection, item).catch(err => {\n console.error(`Error saving ${collection} to IndexedDB:`, err);\n });\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n item.email = email;\n\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) {\n // Roll back the optimistic add so the UI matches the database\n this.cache[collection] = (this.cache[collection] || []).filter(x => x.id !== item.id);\n this.emit(collection, this.cache[collection]);\n this._notifyWriteError('save', collection, error);\n }\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n // Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n this.writeRecordToIndexedDB(collection, updated).catch(err => {\n console.error(`Error saving updated ${collection} to IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const isAuthUpdate = updates.username !== undefined ||\n updates.email !== undefined ||\n updates.password !== undefined ||\n updates.name !== undefined ||\n updates.role !== undefined ||\n updates.userTypeId !== undefined ||\n updates.color !== undefined ||\n updates.payRate !== undefined;\n\n if (isAuthUpdate) {\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to update user.');\n }\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n const dbPayload = this.denormalizeRecord(updated, 'technicians');\n const { error: dbError } = await supabase\n .from('profiles')\n .update(dbPayload)\n .eq('id', id);\n\n if (dbError) {\n throw dbError;\n }\n\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 2b. Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Roll back to the previous version so the UI matches the database\n const arr = [...(this.cache[collection] || [])];\n const i = arr.findIndex(x => x.id === id);\n if (i !== -1) { arr[i] = previous; this.cache[collection] = arr; this.emit(collection, arr); }\n this._notifyWriteError('update', collection, error);\n }\n });\n }\n\n return updated;\n }\n\n // Child records that must not outlive their parent. Deleting a parent cascades to\n // these (scheduling/alert artifacts). Financial/labor records (invoices, timesheets)\n // are deliberately KEPT — they're history and carry their own denormalised numbers.\n _cascadeDelete(collection, id) {\n const CASCADES = {\n jobs: [ ['schedule', 'jobId'], ['formInstances', 'jobId'], ['notifications', 'jobId'] ],\n assets: [ ['maintenancePlans', 'assetId'], ['notifications', 'assetId'] ],\n formTemplates: [ ['formInstances', 'templateId'] ],\n };\n (CASCADES[collection] || []).forEach(([childCol, fk]) => {\n (this.cache[childCol] || []).filter(c => c[fk] === id).forEach(c => this.delete(childCol, c.id));\n });\n }\n\n delete(collection, id) {\n // 0. Cascade-remove dependent child records BEFORE the parent leaves the cache\n this._cascadeDelete(collection, id);\n\n // 1. Update memory cache (keep a copy of the removed row for rollback)\n const removed = (this.cache[collection] || []).find(item => item.id === id);\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Deleting an invoice frees any jobs it (alone) had marked \"Invoiced\"\n if (collection === 'invoices' && removed) {\n this._revertJobsOnInvoiceDelete(removed);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n this.deleteRecordFromIndexedDB(collection, id).catch(err => {\n console.error(`Error deleting ${id} from ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Restore the row we optimistically removed\n if (removed) { this.cache[collection] = [...(this.cache[collection] || []), removed]; this.emit(collection, this.cache[collection]); }\n this._notifyWriteError('delete', collection, error);\n }\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n /**\n * Generate the next sequential number for a collection.\n * Scans existing records, finds the highest numeric suffix, and returns prefix + (max+1) zero-padded to 5 digits.\n * e.g. getNextNumber('INV-', 'invoices') → 'INV-00001', 'INV-00002', ...\n */\n getNextNumber(defaultPrefix, collection) {\n const settings = this.getSettings();\n const dt = settings.documentTheme || {};\n \n let prefix = defaultPrefix;\n let startingNum = 1;\n\n if (collection === 'invoices') {\n prefix = dt.invoicePrefix !== undefined ? dt.invoicePrefix : 'INV-';\n startingNum = dt.invoiceStartingNumber !== undefined ? parseInt(dt.invoiceStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n } else if (collection === 'quotes') {\n prefix = dt.quotePrefix !== undefined ? dt.quotePrefix : 'Q-';\n startingNum = dt.quoteStartingNumber !== undefined ? parseInt(dt.quoteStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n }\n\n const items = this.getAll(collection) || [];\n let maxNum = startingNum - 1;\n\n items.forEach(item => {\n if (item.number && typeof item.number === 'string' && item.number.startsWith(prefix)) {\n const numStr = item.number.slice(prefix.length);\n const num = parseInt(numStr, 10);\n if (!isNaN(num) && num > maxNum) {\n maxNum = num;\n }\n }\n });\n\n return prefix + (maxNum + 1).toString().padStart(5, '0');\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: defaultLogoLarge,\n logoSmall: defaultLogoSmall,\n markupPercent: 20,\n taxEnabled: true,\n taxRate: 10,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n jobTypes: ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'],\n supplierCategories: ['Electrical', 'Plumbing', 'HVAC', 'Fire Safety', 'Security', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n },\n ai: {\n enabled: (this.companyId && !this.companyId.startsWith('acct_')) ? !!(typeof import.meta.env !== 'undefined' && (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY)) : false,\n apiKey: (this.companyId && !this.companyId.startsWith('acct_')) ? (typeof import.meta.env !== 'undefined' ? (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY || '') : '') : '',\n endpoint: 'https://api.openai.com/v1/chat/completions',\n model: 'gpt-4o-mini',\n systemPrompt: 'You are Relay, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default logos if user has not uploaded custom ones\n if (!merged.logo || merged.logo.includes('logo-large.png')) merged.logo = defaultLogoLarge;\n if (!merged.logoSmall) merged.logoSmall = defaultLogoSmall;\n if (!merged.jobTypes) merged.jobTypes = [...defaultSettings.jobTypes];\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n merged.ai = { ...defaultSettings.ai, ...this.companySettings.ai };\n return merged;\n }\n\n return defaultSettings;\n }\n\n getTaxRate() {\n const settings = this.getSettings();\n if (settings.taxEnabled === false) return 0;\n const rate = settings.taxRate !== undefined ? Number(settings.taxRate) : 10;\n return rate / 100;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('settings'), JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── v1.3 Maps: per-user dispatch start location ────────────────────────────\n // Writes profiles.start_location directly (the technicians update() path goes\n // through the invite-user edge function, which doesn't carry this field).\n // Pass null to clear the override (falls back to the company office).\n async setStartLocation(startLocation) {\n const currentUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null') : null;\n if (!currentUser?.id) throw new Error('Not signed in');\n\n // Update cache first so getStartLocation() sees it immediately\n const techs = [...(this.cache.technicians || [])];\n const i = techs.findIndex(t => t.id === currentUser.id);\n if (i !== -1) {\n techs[i] = { ...techs[i], startLocation };\n this.cache.technicians = techs;\n this.emit('technicians', techs);\n }\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n // Local mode: persist with the rest of the technician record\n await this.writeRecordToIndexedDB?.('technicians', techs[i]).catch?.(() => {});\n return;\n }\n const { error } = await supabase\n .from('profiles')\n .update({ start_location: startLocation })\n .eq('id', currentUser.id);\n if (error) {\n this._notifyWriteError('update', 'start location', error);\n throw error;\n }\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId && !this.companyId.startsWith('acct_')) return true;\n return localStorage.getItem(this.getStorageKey('seeded')) === 'true';\n }\n\n save(collection, items) {\n const prev = this.cache[collection] || [];\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const p = this.writeAllToIndexedDB(collection, items).catch(err => {\n console.error(`Error saving all items to ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, items);\n }\n return p;\n }\n\n // Cloud mode: persist the whole collection. Upsert every current row, then delete\n // any rows that were removed from the array. (Previously this only touched the\n // in-memory cache, so every save() — assign-tech, role edits, form templates,\n // portal edits, stock receiving, mark-read, etc. — silently failed to persist.)\n const table = TABLE_MAP[collection];\n if (!table) return; // cache-only collections (e.g. 'activity') have no table\n\n const payload = items\n .filter(it => it && it.id)\n .map(it => this.denormalizeRecord({ ...it, companyId: this.companyId }, collection));\n if (payload.length) {\n supabase.from(table).upsert(payload).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n\n const currentIds = new Set(items.map(it => it && it.id));\n const removedIds = prev.filter(p => p && p.id && !currentIds.has(p.id)).map(p => p.id);\n if (removedIds.length) {\n supabase.from(table).delete().in('id', removedIds).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n }\n\n markSeeded() {\n localStorage.setItem(this.getStorageKey('seeded'), 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('formTemplates', templates);\n return;\n }\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n async seedDefaultUserTypes() {\n if (!this.companyId) return;\n\n const userTypes = [\n {\n id: `${this.companyId}_ut_admin`,\n name: 'Admin',\n description: 'Full system access',\n permissions: buildGranularPerms(() => true)\n },\n {\n id: `${this.companyId}_ut_manager`,\n name: 'Manager',\n description: 'Can manage most workflows but limited settings access',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company', 'manage_tax'].includes(key);\n return true;\n })\n },\n {\n id: `${this.companyId}_ut_tech`,\n name: 'Technician',\n description: 'Field staff — limited to their own jobs, schedule and timesheets',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n {\n id: `${this.companyId}_ut_office`,\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders', 'Suppliers'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n\n this.cache.userTypes = userTypes;\n this.emit('userTypes', userTypes);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('userTypes', userTypes);\n return;\n }\n\n const dbPayload = userTypes.map(ut => this.denormalizeRecord({ ...ut, companyId: this.companyId }, 'userTypes'));\n const { error } = await supabase.from('user_types').insert(dbPayload);\n if (error) console.error('Error seeding default user types:', error);\n }\n\n async seedDefaultTechnicians() {\n const companyId = this.companyId;\n if (!companyId) return;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const managerTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_manager` : 'ut_manager';\n const techTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_tech` : 'ut_tech';\n const officeTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_office` : 'ut_office';\n\n const defaultTechs = [\n { id: `${companyId}_tech_1`, name: 'Jake Morrow', role: 'Senior Electrician', color: '#3B82F6', userTypeId: adminTypeId, payRate: 95.00, email: 'jake@apexpowerservices.com.au', phone: '0412 233 445', username: 'jake', password: '123456' },\n { id: `${companyId}_tech_2`, name: 'Ryan Holt', role: 'Service Manager', color: '#10B981', userTypeId: managerTypeId, payRate: 85.00, email: 'ryan@apexpowerservices.com.au', phone: '0423 344 556', username: 'ryan', password: '123456' },\n { id: `${companyId}_tech_3`, name: 'Sandra Okafor', role: 'Electrician', color: '#8B5CF6', userTypeId: techTypeId, payRate: 80.00, email: 'sandra@apexpowerservices.com.au', phone: '0434 455 667', username: 'sandra', password: '123456' },\n { id: `${companyId}_tech_4`, name: 'Dean Caruso', role: 'Office Administrator',color: '#F59E0B', userTypeId: officeTypeId, payRate: 50.00, email: 'dean@apexpowerservices.com.au', phone: '0445 566 778', username: 'dean', password: '123456' }\n ];\n\n this.cache.technicians = defaultTechs;\n this.emit('technicians', defaultTechs);\n\n if (companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('technicians', defaultTechs);\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder('technicians', defaultTechs).catch(err => {\n console.error('Error writing seeded technicians to local folder:', err);\n });\n }\n return;\n }\n\n const dbPayload = defaultTechs.map(t => this.denormalizeRecord({ ...t, companyId }, 'technicians'));\n const { error } = await supabase.from('technicians').insert(dbPayload);\n if (error) console.error('Error seeding default technicians:', error);\n }\n\n async migrateLocalToCloud(companyId, adminUserId) {\n this.companyId = companyId;\n this.userId = adminUserId;\n\n // 1. Update the remote company settings with local companySettings\n const currentSettings = this.getSettings();\n const { error: compErr } = await supabase.from('companies').update({\n abn: currentSettings.abn || '',\n phone: currentSettings.phone || '',\n domain: currentSettings.domain || '',\n email: currentSettings.email || '',\n address: currentSettings.address || '',\n settings: currentSettings\n }).eq('id', companyId);\n if (compErr) console.error('Error updating cloud company settings during migration:', compErr);\n\n // 2. Loop through all collections and push to Supabase\n // Skip 'companies' and 'technicians'\n const collectionsToMigrate = Object.keys(TABLE_MAP).filter(col => col !== 'companies' && col !== 'technicians');\n\n for (const col of collectionsToMigrate) {\n const items = this.cache[col] || [];\n if (items.length === 0) continue;\n\n const table = TABLE_MAP[col];\n if (!table) continue;\n\n // Assign the new cloud company_id and denormalize\n const payload = items.map(item => {\n const itemCopy = { ...item, companyId };\n return this.denormalizeRecord(itemCopy, col);\n });\n\n // Write to Supabase using upsert\n const { error } = await supabase.from(table).upsert(payload);\n if (error) {\n console.error(`Error migrating collection ${col}:`, error);\n throw new Error(`Failed to migrate ${col} data: ${error.message}`);\n }\n }\n\n // 3. Clear/close local DB connection and sync from Cloud\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n }\n\n deleteLocalAccountData(accountId) {\n return new Promise((resolve) => {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n if (typeof localStorage !== 'undefined') {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem(`relay_${accountId}_${col}`);\n });\n localStorage.removeItem(`relay_${accountId}_folder_sync_enabled`);\n localStorage.removeItem(`relay_${accountId}_seeded`);\n localStorage.removeItem(`relay_${accountId}_settings`);\n }\n if (typeof window !== 'undefined' && window.indexedDB) {\n const req = window.indexedDB.deleteDatabase(`RelayDispatchDB_${accountId}`);\n req.onsuccess = () => resolve();\n req.onerror = () => resolve();\n req.onblocked = () => {\n console.warn('Database deletion blocked, resolving anyway');\n resolve();\n };\n } else {\n resolve();\n }\n });\n }\n\n async clearAll() {\n this.clearSync();\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n \n if (activeAccount) {\n await this.deleteLocalAccountData(activeAccount);\n } else {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n this.dirHandle = null;\n try {\n await this.clearAllIndexedDB();\n } catch (err) {\n console.error('Error clearing IndexedDB:', err);\n }\n }\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "1398a829c78f795f0e98df1972a0aedeeb505204": {"t": 1785754175.9513571, "n": 115461, "h": "d4e79b347e94e17c", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\nconst defaultLogoLarge = new URL('../assets/RELAY_Dispatch_Logo.png', import.meta.url).href;\nconst defaultLogoSmall = new URL('../assets/logo-small.png', import.meta.url).href;\n\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n passwordResetRequests: 'password_reset_requests',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents',\n leads: 'leads',\n schedule: 'schedule',\n projects: 'projects',\n costCenters: 'cost_centers',\n deputyAsks: null\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"deactivated\",\n \"deactivated_at\",\n \"start_location\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"project_id\",\n \"cost_center_id\",\n \"created_at\",\n \"updated_at\"\n ],\n projects: [\n \"id\",\n \"company_id\",\n \"number\",\n \"name\",\n \"customer_id\",\n \"customer_name\",\n \"site_address\",\n \"status\",\n \"description\",\n \"start_date\",\n \"end_date\",\n \"created_at\",\n \"updated_at\"\n ],\n costCenters: [\n \"id\",\n \"company_id\",\n \"name\",\n \"code\",\n \"active\",\n \"xero_sales_account_code\",\n \"xero_expense_account_code\",\n \"xero_tracking_category_name\",\n \"xero_tracking_option_name\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"paid_date\",\n \"payment_method\",\n \"stripe_session_id\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"license_number\",\n \"hourly_rate\",\n \"after_hours_rate\",\n \"callout_fee\",\n \"specialties\",\n \"notes\",\n \"portal_token\",\n \"compliance_docs\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"address\",\n \"category\",\n \"account_number\",\n \"payment_terms\",\n \"notes\",\n \"attachments\",\n \"created_at\",\n \"updated_at\"\n ],\n leads: [\n \"id\",\n \"company_id\",\n \"number\",\n \"title\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"status\",\n \"source\",\n \"value\",\n \"description\",\n \"priority\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"created_at\",\n \"updated_at\"\n ],\n password_reset_requests: [\n \"id\",\n \"technician_id\",\n \"employee_id\",\n \"requested_at\",\n \"status\",\n \"token\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"type\",\n \"description\",\n \"priority\",\n \"read\",\n \"number\",\n \"asset_id\",\n \"job_id\",\n \"due_date\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"type\",\n \"jobid\",\n \"jobnumber\",\n \"technicianid\",\n \"technicianname\",\n \"date\",\n \"starttime\",\n \"finishtime\",\n \"hours\",\n \"starthour\",\n \"endhour\",\n \"notes\",\n \"status\",\n \"taskid\",\n \"taskname\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g><text x=\"90\" y=\"36\" fill=\"#2D3134\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"25\" font-weight=\"900\" letter-spacing=\"0.04em\">DISPATCH</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nconst MODULE_PERMS = {\n 'Dashboard': [{ key: 'view' }],\n 'Customers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_contacts' }],\n 'Leads': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'convert' }],\n 'Quotes': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'approve' }, { key: 'convert' }, { key: 'generate_pdf' }],\n 'Jobs': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_tasks' }, { key: 'book_time' }, { key: 'view_costs' }, { key: 'view_quotes_tab' }, { key: 'view_pos_tab' }, { key: 'view_timesheets_tab' }, { key: 'view_invoices_tab' }, { key: 'manage_materials' }, { key: 'create_invoice' }],\n 'Timesheets': [{ key: 'view_own' }, { key: 'view' }, { key: 'create' }, { key: 'approve' }, { key: 'edit_all' }, { key: 'export' }],\n 'Assets': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Schedule': [{ key: 'view_own' }, { key: 'view' }, { key: 'edit' }],\n 'Contractors': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }],\n 'Suppliers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Stock': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Purchase Orders': [{ key: 'view' }, { key: 'create' }, { key: 'approve' }],\n 'Invoices': [{ key: 'view' }, { key: 'create' }, { key: 'send' }, { key: 'void' }],\n 'Reports': [{ key: 'view' }, { key: 'export' }],\n 'Documents': [{ key: 'view' }, { key: 'upload' }],\n 'Settings': [{ key: 'view' }, { key: 'edit_company' }, { key: 'manage_users' }, { key: 'manage_tax' }]\n};\n\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n this.db = null;\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n this.backupDirHandle = null;\n this.backupDirPermissionGranted = false;\n\n // Check if we have a cloud user at boot (before filling cache)\n const bootUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n const isCloudUser = !!(bootUser && bootUser.companyId && !bootUser.companyId.startsWith('acct_'));\n\n if (!isCloudUser && bootUser && bootUser.companyId && typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', bootUser.companyId);\n }\n\n // Pre-initialize empty arrays for collections.\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Load local settings if they exist (only for offline/demo mode)\n if (!isCloudUser && typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId && !this.companyId.startsWith('acct_')) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n // Only clear sync if we were previously in cloud sync mode or companyId is not defined\n if (!this.companyId || !this.companyId.startsWith('acct_')) {\n this.clearSync();\n }\n }\n });\n\n // Auto-trigger sync or local load if user already logged in at boot\n if (isCloudUser) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n this.initPromise = this.initializeCloudSync();\n } else {\n if (bootUser && bootUser.companyId) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n }\n this.initPromise = this.initializeLocalStore();\n }\n }\n\n getStorageKey(collection) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (activeAccount) {\n return `relay_${activeAccount}_${collection}`;\n }\n return `simpro_${collection}`;\n }\n\n getDBName() {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n return activeAccount ? `RelayDispatchDB_${activeAccount}` : 'RelayDispatchDB';\n }\n\n async initializeUser(user) {\n if (user && user.companyId && !user.companyId.startsWith('acct_')) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n } else {\n this.clearSync();\n if (user && user.companyId) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', user.companyId);\n }\n } else {\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n }\n this.initPromise = this.initializeLocalStore();\n await this.initPromise;\n }\n }\n\n async initializeLocalStore() {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n\n try {\n await this.initIndexedDB();\n } catch (e) {\n console.error('Failed to initialize IndexedDB, falling back to empty arrays:', e);\n }\n\n // Load local settings if they exist (namespaced)\n this.companySettings = null;\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n this.emit('settings', this.getSettings());\n\n // Seed default user types and forms if empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n if (this.cache.technicians.length === 0) {\n await this.seedDefaultTechnicians();\n }\n }\n\n initIndexedDB() {\n return new Promise((resolve, reject) => {\n if (typeof window === 'undefined' || !window.indexedDB) {\n return resolve();\n }\n\n const dbName = this.getDBName();\n const request = window.indexedDB.open(dbName, 4);\n\n request.onerror = (e) => {\n console.error('IndexedDB open error:', e.target.error);\n reject(e.target.error);\n };\n\n request.onsuccess = async (e) => {\n this.db = e.target.result;\n \n // 1. Load custom directory sync config from IndexedDB config store if it exists\n try {\n if (this.db.objectStoreNames.contains('config')) {\n await new Promise((resConfig) => {\n const transaction = this.db.transaction('config', 'readonly');\n const configStore = transaction.objectStore('config');\n \n const getSync = new Promise((resolve) => {\n const req = configStore.get('directory_handle');\n req.onsuccess = () => {\n console.log('[DEBUG Store] getSync onsuccess result:', req.result);\n if (req.result) {\n this.dirHandle = req.result.value;\n console.log('[DEBUG Store] loaded dirHandle:', this.dirHandle ? this.dirHandle.name : null);\n }\n resolve();\n };\n req.onerror = (e) => {\n console.error('[DEBUG Store] getSync onerror:', e);\n resolve();\n };\n });\n\n const getBackup = new Promise((resolve) => {\n const req = configStore.get('backup_directory_handle');\n req.onsuccess = () => {\n if (req.result) this.backupDirHandle = req.result.value;\n resolve();\n };\n req.onerror = () => resolve();\n });\n\n Promise.all([getSync, getBackup]).then(() => resConfig());\n });\n }\n } catch (err) {\n console.warn('Failed to read config from IndexedDB:', err);\n }\n\n // Verify directory permission silently on boot if handle exists\n if (this.dirHandle) {\n try {\n // Check permission silently without prompt dialog\n const opts = { mode: 'readwrite' };\n const status = await this.dirHandle.queryPermission(opts);\n this.folderSyncPermissionGranted = status === 'granted';\n } catch (err) {\n console.warn('Failed to query directory permission on boot:', err);\n }\n }\n\n // Verify backup directory permission silently on boot if handle exists\n if (this.backupDirHandle) {\n try {\n const opts = { mode: 'readwrite' };\n const status = await this.backupDirHandle.queryPermission(opts);\n this.backupDirPermissionGranted = status === 'granted';\n\n // Auto-trigger backup if granted and > 7 days since last backup\n if (this.backupDirPermissionGranted) {\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;\n if (!lastBackup || new Date(lastBackup).getTime() < sevenDaysAgo) {\n this.backupToFolder().catch(err => {\n console.error('Auto background backup failed:', err);\n });\n }\n }\n } catch (err) {\n console.warn('Failed to query backup directory permission on boot:', err);\n }\n }\n\n // Load all collections into cache\n const collections = Object.keys(TABLE_MAP);\n const loadPromises = collections.map(async (col) => {\n try {\n this.cache[col] = await this.readAllFromIndexedDB(col);\n } catch (err) {\n console.error(`Error loading collection ${col} from IndexedDB:`, err);\n this.cache[col] = [];\n }\n });\n\n await Promise.all(loadPromises);\n\n // Perform migration if we have old data in localStorage (only if not in a namespaced local account profile)\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (!activeAccount) {\n const migratePromises = collections.map(async (col) => {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n const items = JSON.parse(localData);\n if (Array.isArray(items) && items.length > 0) {\n // Write all items to IndexedDB\n await this.writeAllToIndexedDB(col, items);\n // Merge into cache if cache is empty\n if (this.cache[col].length === 0) {\n this.cache[col] = items;\n }\n }\n localStorage.removeItem('simpro_' + col);\n } catch (err) {\n console.error(`Migration error for ${col}:`, err);\n }\n }\n });\n\n await Promise.all(migratePromises);\n }\n resolve();\n };\n\n request.onupgradeneeded = (e) => {\n const db = e.target.result;\n const collections = Object.keys(TABLE_MAP);\n collections.forEach(col => {\n if (!db.objectStoreNames.contains(col)) {\n db.createObjectStore(col, { keyPath: 'id' });\n }\n });\n if (!db.objectStoreNames.contains('config')) {\n db.createObjectStore('config', { keyPath: 'key' });\n }\n };\n });\n }\n\n readAllFromIndexedDB(storeName) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve([]);\n try {\n const transaction = this.db.transaction(storeName, 'readonly');\n const store = transaction.objectStore(storeName);\n const getAllRequest = store.getAll();\n\n getAllRequest.onsuccess = () => {\n resolve(getAllRequest.result || []);\n };\n\n getAllRequest.onerror = (e) => {\n reject(e.target.error);\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeRecordToIndexedDB(storeName, record) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const putRequest = store.put(record);\n\n putRequest.onsuccess = () => resolve();\n putRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n deleteRecordFromIndexedDB(storeName, id) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const deleteRequest = store.delete(id);\n\n deleteRequest.onsuccess = () => resolve();\n deleteRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeAllToIndexedDB(storeName, items) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n \n // Clear existing\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n \n clearRequest.onsuccess = () => {\n let count = 0;\n if (items.length === 0) return resolve();\n \n items.forEach(item => {\n const putRequest = store.put(item);\n putRequest.onerror = (e) => reject(e.target.error);\n putRequest.onsuccess = () => {\n count++;\n if (count === items.length) {\n resolve();\n }\n };\n });\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n clearAllIndexedDB() {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const collections = Object.keys(TABLE_MAP);\n const transaction = this.db.transaction(collections, 'readwrite');\n let count = 0;\n \n collections.forEach(col => {\n const store = transaction.objectStore(col);\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n clearRequest.onsuccess = () => {\n count++;\n if (count === collections.length) {\n resolve();\n }\n };\n });\n } catch (err) {\n reject(err);\n }\n });\n }\n\n // ── Local Folder Sync / File System Access Driver ──────────────────────────\n\n async verifyDirPermission(readWrite) {\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n this.folderSyncPermissionGranted = true;\n return true; // Capacitor native has permissions checked at app level\n }\n\n if (!this.dirHandle) return false;\n const opts = {};\n if (readWrite) opts.mode = 'readwrite';\n \n try {\n const currentPerm = await this.dirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.folderSyncPermissionGranted = true;\n return true;\n }\n \n // Request permission (must be called within a user gesture handler)\n const newPerm = await this.dirHandle.requestPermission(opts);\n this.folderSyncPermissionGranted = newPerm === 'granted';\n return this.folderSyncPermissionGranted;\n } catch (e) {\n this.folderSyncPermissionGranted = false;\n return false;\n }\n }\n\n async verifyBackupDirPermission(requestInteractive) {\n if (!this.backupDirHandle) return false;\n const opts = { mode: 'readwrite' };\n try {\n const currentPerm = await this.backupDirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.backupDirPermissionGranted = true;\n return true;\n }\n if (requestInteractive) {\n const newPerm = await this.backupDirHandle.requestPermission(opts);\n this.backupDirPermissionGranted = newPerm === 'granted';\n return this.backupDirPermissionGranted;\n }\n this.backupDirPermissionGranted = false;\n return false;\n } catch (e) {\n this.backupDirPermissionGranted = false;\n return false;\n }\n }\n\n // Ensure a subfolder for the current company exists within the selected directory.\n async ensureCompanyFolderHandle(customDirHandle) {\n const baseHandle = customDirHandle || this.dirHandle;\n if (!baseHandle) return null;\n const rawName = this.companySettings?.name || this.companySettings?.id || 'Company';\n const safeName = rawName.replace(/[\\\\/:*?\"<>|]/g, '_');\n try {\n const companyHandle = await baseHandle.getDirectoryHandle(safeName, { create: true });\n return companyHandle;\n } catch (e) {\n console.error('Failed to get/create company folder:', e);\n return null;\n }\n }\n\n async setBackupDirectory(dirHandle) {\n this.backupDirHandle = dirHandle || null;\n this.backupDirPermissionGranted = !!dirHandle;\n \n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n if (dirHandle) {\n const putReq = configStore.put({ key: 'backup_directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = () => resolve();\n } else {\n const deleteReq = configStore.delete('backup_directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n }\n });\n }\n }\n\n async backupToFolder(dirHandle) {\n const handle = dirHandle || this.backupDirHandle;\n if (!handle) throw new Error('No backup directory configured.');\n \n const currentPerm = await handle.queryPermission({ mode: 'readwrite' });\n if (currentPerm !== 'granted') {\n const newPerm = await handle.requestPermission({ mode: 'readwrite' });\n if (newPerm !== 'granted') {\n throw new Error('Write permission denied for backup directory.');\n }\n }\n \n // Ensure company-specific folder exists within the selected directory\n const companyFolderHandle = await this.ensureCompanyFolderHandle(dirHandle);\n const dataDir = await companyFolderHandle.getDirectoryHandle('data', { create: true });\n const collections = Object.keys(TABLE_MAP);\n for (const col of collections) {\n const items = this.cache[col] || [];\n const fileHandle = await dataDir.getFileHandle(`${col}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items, null, 2));\n await writable.close();\n }\n \n // Update last backup timestamp\n localStorage.setItem('relay_last_backup_time', new Date().toISOString());\n this.backupDirPermissionGranted = true;\n }\n\n async setLocalDirectory(dirHandle) {\n this.dirHandle = dirHandle;\n if (dirHandle) {\n this.folderSyncEnabled = true;\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('folder_sync_enabled'), 'true');\n }\n \n // Save handle to IndexedDB\n if (this.db) {\n await new Promise((resolve, reject) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const putReq = configStore.put({ key: 'directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = (e) => reject(e.target.error);\n });\n }\n \n this.folderSyncPermissionGranted = true;\n \n // Force rewrite all cached collections to directory immediately\n const collections = Object.keys(TABLE_MAP);\n await Promise.all(collections.map(col => this.writeCollectionToFolder(col, this.cache[col])));\n } else {\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n \n // Remove handle from IndexedDB\n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const deleteReq = configStore.delete('directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n });\n }\n }\n }\n\n async writeCollectionToFolder(collection, items) {\n if (!this.folderSyncEnabled) return;\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/data/${collection}.json`,\n data: JSON.stringify(items || [], null, 2),\n directory: 'DOCUMENTS',\n encoding: 'utf8',\n recursive: true\n });\n } catch (err) {\n console.error(`Capacitor failed to write collection ${collection}:`, err);\n }\n return;\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return;\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return;\n const dataDir = await companyFolder.getDirectoryHandle('data', { create: true });\n const fileHandle = await dataDir.getFileHandle(`${collection}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items || [], null, 2));\n await writable.close();\n } catch (err) {\n console.error(`Failed to write collection ${collection} to local directory:`, err);\n }\n }\n\n async writeDocumentFileToFolder(docId, name, dataUrl) {\n if (!this.folderSyncEnabled) return null;\n\n const ext = name.includes('.') ? name.split('.').pop() : 'bin';\n const fileName = `${docId}.${ext}`;\n const relativePath = `documents/${fileName}`;\n\n // Extract base64 payload\n const base64Parts = dataUrl.split(',');\n if (base64Parts.length < 2) return null;\n const base64Data = base64Parts[1];\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/documents/${fileName}`,\n data: base64Data,\n directory: 'DOCUMENTS',\n recursive: true\n });\n return relativePath;\n } catch (err) {\n console.error('Capacitor failed to write document:', err);\n return null;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return null;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return null;\n\n const mimeMatch = base64Parts[0].match(/:(.*?);/);\n const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';\n const binaryStr = atob(base64Data);\n const len = binaryStr.length;\n const bytes = new Uint8Array(len);\n for (let i = 0; i < len; i++) {\n bytes[i] = binaryStr.charCodeAt(i);\n }\n const blob = new Blob([bytes], { type: mime });\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return null;\n const docsDir = await companyFolder.getDirectoryHandle('documents', { create: true });\n const fileHandle = await docsDir.getFileHandle(fileName, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(blob);\n await writable.close();\n\n return relativePath;\n } catch (err) {\n console.error('Failed to save document file to local directory:', err);\n return null;\n }\n }\n\n async getDocumentUrl(doc) {\n if (!doc || !doc.url) return '';\n if (doc.url.startsWith('data:') || doc.url.startsWith('http')) {\n return doc.url;\n }\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n const result = await Filesystem.readFile({\n path: `RelayDispatchData/${doc.url}`,\n directory: 'DOCUMENTS'\n });\n const mime = doc.type || 'application/octet-stream';\n return `data:${mime};base64,${result.data}`;\n } catch (err) {\n console.error('Capacitor failed to read document file:', err);\n throw err;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) {\n throw new Error('Local directory sync is not configured.');\n }\n\n try {\n const hasPerm = await this.verifyDirPermission(false);\n if (!hasPerm) {\n throw new Error('Permission to access the local directory was not granted.');\n }\n\n const parts = doc.url.split('/');\n const dirName = parts[0];\n const fileName = parts[1];\n\n const dir = await this.dirHandle.getDirectoryHandle(dirName);\n const fileHandle = await dir.getFileHandle(fileName);\n const file = await fileHandle.getFile();\n return URL.createObjectURL(file);\n } catch (err) {\n console.error('Failed to read file from local directory:', err);\n throw err;\n }\n }\n\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // Initialize IndexedDB for cloud users too, to store backup directory handles\n await this.initIndexedDB();\n\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id\n query = query.eq('company_id', this.companyId);\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n await this.writeAllToIndexedDB(col, this.cache[col]);\n }\n });\n\n await Promise.all(promises);\n\n // Seed default user types if database is empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n // Unique channel name per subscribe — reusing a fixed name throws\n // \"cannot add postgres_changes callbacks ... after subscribe()\" when sync re-inits.\n const channel = supabase\n .channel(`table-db-changes-${this.companyId || 'anon'}-${Date.now()}`)\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n if (collection === 'contractors') {\n record.businessName = record.name;\n record.active = record.status === 'Active';\n }\n\n if (collection === 'suppliers') {\n record.active = record.status === 'Active';\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.project_id !== undefined) {\n record.projectId = record.project_id;\n delete record.project_id;\n }\n if (record.cost_center_id !== undefined) {\n record.costCenterId = record.cost_center_id;\n delete record.cost_center_id;\n }\n if (record.xero_sales_account_code !== undefined) {\n record.xeroSalesAccountCode = record.xero_sales_account_code;\n delete record.xero_sales_account_code;\n }\n if (record.xero_expense_account_code !== undefined) {\n record.xeroExpenseAccountCode = record.xero_expense_account_code;\n delete record.xero_expense_account_code;\n }\n if (record.xero_tracking_category_name !== undefined) {\n record.xeroTrackingCategoryName = record.xero_tracking_category_name;\n delete record.xero_tracking_category_name;\n }\n if (record.xero_tracking_option_name !== undefined) {\n record.xeroTrackingOptionName = record.xero_tracking_option_name;\n delete record.xero_tracking_option_name;\n }\n if (record.deactivated_at !== undefined) {\n record.deactivatedAt = record.deactivated_at;\n delete record.deactivated_at;\n }\n if (record.start_date !== undefined) {\n record.startDate = record.start_date;\n delete record.start_date;\n }\n if (record.end_date !== undefined) {\n record.endDate = record.end_date;\n delete record.end_date;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (collection === 'schedule') {\n if (record.jobid !== undefined) { record.jobId = record.jobid; delete record.jobid; }\n if (record.jobnumber !== undefined) { record.jobNumber = record.jobnumber; delete record.jobnumber; }\n if (record.technicianid !== undefined) { record.technicianId = record.technicianid; delete record.technicianid; }\n if (record.technicianname !== undefined) { record.technicianName = record.technicianname; delete record.technicianname; }\n if (record.starttime !== undefined) { record.startTime = record.starttime; delete record.starttime; }\n if (record.finishtime !== undefined) { record.finishTime = record.finishtime; delete record.finishtime; }\n if (record.starthour !== undefined) { record.startHour = record.starthour != null ? parseFloat(record.starthour) : null; delete record.starthour; }\n if (record.endhour !== undefined) { record.endHour = record.endhour != null ? parseFloat(record.endhour) : null; delete record.endhour; }\n if (record.taskid !== undefined) { record.taskId = record.taskid; delete record.taskid; }\n if (record.taskname !== undefined) { record.taskName = record.taskname; delete record.taskname; }\n }\n\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.paid_date !== undefined) {\n record.paidDate = record.paid_date;\n delete record.paid_date;\n }\n if (record.payment_method !== undefined) {\n record.paymentMethod = record.payment_method;\n delete record.payment_method;\n }\n if (record.stripe_session_id !== undefined) {\n record.stripeSessionId = record.stripe_session_id;\n delete record.stripe_session_id;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined || record.durationHours !== undefined || record.hours !== undefined) {\n const h = parseFloat(record.duration_hours !== undefined ? record.duration_hours : (record.durationHours !== undefined ? record.durationHours : record.hours)) || 0;\n record.durationHours = h;\n record.hours = h;\n delete record.duration_hours;\n }\n if (record.task_name !== undefined) { record.taskName = record.task_name; delete record.task_name; }\n if (record.task_id !== undefined) { record.taskId = record.task_id; delete record.task_id; }\n if (record.task_path !== undefined) { record.taskPath = record.task_path; delete record.task_path; }\n if (record.phase_name !== undefined) { record.phaseName = record.phase_name; delete record.phase_name; }\n if (record.phase_id !== undefined) { record.phaseId = record.phase_id; delete record.phase_id; }\n if (record.start_time !== undefined) { record.startTime = record.start_time; delete record.start_time; }\n if (record.finish_time !== undefined) { record.finishTime = record.finish_time; delete record.finish_time; }\n if (record.approved_by && typeof record.approved_by === 'string' && record.approved_by.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.approved_by.substring(9));\n if (parsed.taskId !== undefined) record.taskId = parsed.taskId;\n if (parsed.taskName !== undefined) record.taskName = parsed.taskName;\n if (parsed.taskPath !== undefined) record.taskPath = parsed.taskPath;\n if (parsed.phaseId !== undefined) record.phaseId = parsed.phaseId;\n if (parsed.phaseName !== undefined) record.phaseName = parsed.phaseName;\n if (parsed.startTime !== undefined) record.startTime = parsed.startTime;\n if (parsed.finishTime !== undefined) record.finishTime = parsed.finishTime;\n if (parsed.description !== undefined) record.description = parsed.description;\n if (parsed.jobNumber !== undefined) record.jobNumber = parsed.jobNumber;\n if (parsed.jobTitle !== undefined) record.jobTitle = parsed.jobTitle;\n if (parsed.hours !== undefined) {\n record.hours = parsed.hours;\n record.durationHours = parsed.hours;\n }\n record.approvedBy = parsed.approvedBy || null;\n } catch (e) {\n console.error(e);\n }\n delete record.approved_by;\n } else if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.license_number !== undefined) { record.licenseNumber = record.license_number; delete record.license_number; }\n if (record.hourly_rate !== undefined) { record.hourlyRate = parseFloat(record.hourly_rate); delete record.hourly_rate; }\n if (record.after_hours_rate !== undefined) { record.afterHoursRate = parseFloat(record.after_hours_rate); delete record.after_hours_rate; }\n if (record.callout_fee !== undefined) { record.calloutFee = parseFloat(record.callout_fee); delete record.callout_fee; }\n if (record.compliance_docs !== undefined) { record.complianceDocs = record.compliance_docs; delete record.compliance_docs; }\n if (record.account_number !== undefined) { record.accountNumber = record.account_number; delete record.account_number; }\n if (record.payment_terms !== undefined) { record.paymentTerms = record.payment_terms; delete record.payment_terms; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.start_location !== undefined) { record.startLocation = record.start_location; delete record.start_location; }\n if (record.day_offset !== undefined) { record.dayOffset = parseInt(record.day_offset); delete record.day_offset; }\n if (record.start_hour !== undefined) { record.startHour = parseFloat(record.start_hour); delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = parseFloat(record.end_hour); delete record.end_hour; }\n\n if (record.color && record.color.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.color.substring(9));\n record.taskId = parsed.taskId;\n record.taskPath = parsed.taskId;\n record.taskName = parsed.taskName;\n record.color = parsed.color;\n if (parsed.date !== undefined) record.date = parsed.date;\n if (parsed.startTime !== undefined) record.startTime = parsed.startTime;\n if (parsed.finishTime !== undefined) record.finishTime = parsed.finishTime;\n if (parsed.hours !== undefined) record.hours = parsed.hours;\n if (parsed.startHour !== undefined && parsed.startHour !== null) record.startHour = parsed.startHour;\n if (parsed.endHour !== undefined && parsed.endHour !== null) record.endHour = parsed.endHour;\n if (parsed.dayOffset !== undefined && parsed.dayOffset !== null) record.dayOffset = parsed.dayOffset;\n } catch (e) {\n console.error(e);\n }\n }\n if (collection === 'invoices') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.invoiceType = 'Standard';\n record.laborProfileId = '';\n record.issueDate = '';\n record.originalQuoteId = '';\n record.originalQuoteNumber = '';\n record.originalSubtotal = 0;\n record.approvedVariationsSum = 0;\n record.pendingVariationsSum = 0;\n record.totalInternalCost = 0;\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.invoiceType = meta.invoiceType || 'Standard';\n record.laborProfileId = meta.laborProfileId || '';\n record.issueDate = meta.issueDate || '';\n record.originalQuoteId = meta.originalQuoteId || '';\n record.originalQuoteNumber = meta.originalQuoteNumber || '';\n record.originalSubtotal = meta.originalSubtotal || 0;\n record.approvedVariationsSum = meta.approvedVariationsSum || 0;\n record.pendingVariationsSum = meta.pendingVariationsSum || 0;\n record.totalInternalCost = meta.totalInternalCost || 0;\n record.jobIds = meta.jobIds || []; // consolidated invoices link multiple jobs\n }\n }\n if (collection === 'quotes') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.laborProfileId = '';\n record.description = '';\n record.isTemplate = false;\n record.templateId = '';\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.laborProfileId = meta.laborProfileId || '';\n record.description = meta.description || '';\n record.isTemplate = meta.isTemplate || false;\n record.templateId = meta.templateId || '';\n }\n }\n if (collection === 'jobs') {\n if (record.notes && record.notes.startsWith('__meta__:')) {\n try {\n const meta = JSON.parse(record.notes.substring(9));\n record.isRecurring = meta.isRecurring || false;\n record.recurringConfig = meta.recurringConfig || null;\n record.preferredTime = meta.preferredTime || '';\n record.description = meta.description || '';\n record.parentJobId = meta.parentJobId || null;\n record.assetName = meta.assetName || '';\n record.notes = meta.notes || '';\n } catch (e) {\n console.error('Error parsing jobs meta:', e);\n }\n }\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (collection === 'contractors') {\n record.name = record.businessName;\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (collection === 'suppliers') {\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.xeroSalesAccountCode !== undefined) {\n record.xero_sales_account_code = record.xeroSalesAccountCode;\n delete record.xeroSalesAccountCode;\n }\n if (record.xeroExpenseAccountCode !== undefined) {\n record.xero_expense_account_code = record.xeroExpenseAccountCode;\n delete record.xeroExpenseAccountCode;\n }\n if (record.xeroTrackingCategoryName !== undefined) {\n record.xero_tracking_category_name = record.xeroTrackingCategoryName;\n delete record.xeroTrackingCategoryName;\n }\n if (record.xeroTrackingOptionName !== undefined) {\n record.xero_tracking_option_name = record.xeroTrackingOptionName;\n delete record.xeroTrackingOptionName;\n }\n if (record.deactivatedAt !== undefined) {\n record.deactivated_at = record.deactivatedAt;\n delete record.deactivatedAt;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.projectId !== undefined) {\n record.project_id = record.projectId;\n delete record.projectId;\n }\n if (record.costCenterId !== undefined) {\n record.cost_center_id = record.costCenterId;\n delete record.costCenterId;\n }\n if (record.startDate !== undefined) {\n record.start_date = record.startDate;\n delete record.startDate;\n }\n if (record.endDate !== undefined) {\n record.end_date = record.endDate;\n delete record.endDate;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.paidDate !== undefined) {\n record.paid_date = record.paidDate;\n delete record.paidDate;\n }\n if (record.paymentMethod !== undefined) {\n record.payment_method = record.paymentMethod;\n delete record.paymentMethod;\n }\n if (record.stripeSessionId !== undefined) {\n record.stripe_session_id = record.stripeSessionId;\n delete record.stripeSessionId;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined || record.hours !== undefined) {\n const h = parseFloat(record.durationHours !== undefined ? record.durationHours : record.hours) || 0;\n record.duration_hours = h;\n record.durationHours = h;\n record.hours = h;\n }\n if (record.taskName !== undefined) record.task_name = record.taskName;\n if (record.taskId !== undefined) record.task_id = record.taskId;\n if (record.taskPath !== undefined) record.task_path = record.taskPath;\n if (record.phaseName !== undefined) record.phase_name = record.phaseName;\n if (record.phaseId !== undefined) record.phase_id = record.phaseId;\n if (record.startTime !== undefined) record.start_time = record.startTime;\n if (record.finishTime !== undefined) record.finish_time = record.finishTime;\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.licenseNumber !== undefined) { record.license_number = record.licenseNumber; delete record.licenseNumber; }\n if (record.hourlyRate !== undefined) { record.hourly_rate = record.hourlyRate; delete record.hourlyRate; }\n if (record.afterHoursRate !== undefined) { record.after_hours_rate = record.afterHoursRate; delete record.afterHoursRate; }\n if (record.calloutFee !== undefined) { record.callout_fee = record.calloutFee; delete record.calloutFee; }\n if (record.complianceDocs !== undefined) { record.compliance_docs = record.complianceDocs; delete record.complianceDocs; }\n if (record.accountNumber !== undefined) { record.account_number = record.accountNumber; delete record.accountNumber; }\n if (record.paymentTerms !== undefined) { record.payment_terms = record.paymentTerms; delete record.paymentTerms; }\n if (record.jobNumber !== undefined) { record.job_number = record.jobNumber; delete record.jobNumber; }\n if (record.startLocation !== undefined) { record.start_location = record.startLocation; delete record.startLocation; }\n if (record.dayOffset !== undefined) { record.day_offset = record.dayOffset; delete record.dayOffset; }\n if (record.startHour !== undefined) { record.start_hour = record.startHour; delete record.startHour; }\n if (record.endHour !== undefined) { record.end_hour = record.endHour; delete record.endHour; }\n\n if (collection === 'schedule') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || null,\n color: record.color || null,\n date: record.date || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n hours: record.hours || null,\n startHour: record.startHour !== undefined ? record.startHour : null,\n endHour: record.endHour !== undefined ? record.endHour : null,\n dayOffset: record.dayOffset !== undefined ? record.dayOffset : null\n };\n record.color = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'jobs') {\n const meta = {\n isRecurring: record.isRecurring || false,\n recurringConfig: record.recurringConfig || null,\n preferredTime: record.preferredTime || '',\n description: record.description || '',\n parentJobId: record.parentJobId || null,\n assetName: record.assetName || '',\n notes: record.notes || ''\n };\n record.notes = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'invoices') {\n const meta = {\n sections: record.sections || [],\n invoiceType: record.invoiceType || 'Standard',\n laborProfileId: record.laborProfileId || '',\n issueDate: record.issueDate || '',\n originalQuoteId: record.originalQuoteId || '',\n originalQuoteNumber: record.originalQuoteNumber || '',\n originalSubtotal: record.originalSubtotal || 0,\n approvedVariationsSum: record.approvedVariationsSum || 0,\n pendingVariationsSum: record.pendingVariationsSum || 0,\n totalInternalCost: record.totalInternalCost || 0,\n jobIds: record.jobIds || [] // consolidated invoices link multiple jobs\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n if (collection === 'quotes') {\n const meta = {\n sections: record.sections || [],\n laborProfileId: record.laborProfileId || '',\n description: record.description || '',\n isTemplate: record.isTemplate || false,\n templateId: record.templateId || ''\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n if (collection === 'timesheets') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || record.phaseName || null,\n taskPath: record.taskPath || null,\n phaseId: record.phaseId || null,\n phaseName: record.phaseName || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n description: record.description || null,\n hours: record.hours !== undefined ? record.hours : record.duration_hours,\n approvedBy: record.approvedBy || null,\n jobNumber: record.jobNumber || null,\n jobTitle: record.jobTitle || null\n };\n record.approved_by = '__meta__:' + JSON.stringify(meta);\n delete record.approvedBy;\n }\n\n if (collection === 'schedule') {\n record.jobid = record.jobId || null; delete record.jobId;\n record.jobnumber = record.jobNumber || null; delete record.jobNumber;\n record.technicianid = record.technicianId || null; delete record.technicianId;\n record.technicianname = record.technicianName || null; delete record.technicianName;\n record.starttime = record.startTime || null; delete record.startTime;\n record.finishtime = record.finishTime || null; delete record.finishTime;\n record.starthour = record.startHour !== undefined ? record.startHour : null; delete record.startHour;\n record.endhour = record.endHour !== undefined ? record.endHour : null; delete record.endHour;\n record.taskid = record.taskId || null; delete record.taskId;\n record.taskname = record.taskName || null; delete record.taskName;\n }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n const items = this.cache[collection] || [];\n if (collection === 'technicians') {\n const loginMode = typeof localStorage !== 'undefined' ? localStorage.getItem('relay_login_mode') : null;\n if (loginMode === 'local') {\n const currentUserStr = typeof localStorage !== 'undefined' ? localStorage.getItem('currentUser') : null;\n if (currentUserStr) {\n const currentUser = JSON.parse(currentUserStr);\n if (currentUser && currentUser.id) {\n return [{\n id: currentUser.id,\n name: currentUser.name || 'Local Admin',\n role: 'Administrator',\n color: currentUser.color || '#FF5C00',\n userTypeId: currentUser.userTypeId,\n email: currentUser.email || '',\n username: currentUser.username || ''\n }];\n }\n }\n }\n }\n return items;\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Surface a failed background write to the user instead of failing silently.\n // (A silent insert/update failure leaves the optimistic cache entry in place, so the\n // record looks saved until the next reload — the classic \"it didn't save\" symptom.)\n _notifyWriteError(action, collection, error) {\n console.error(`Error trying to ${action} ${collection}:`, error?.message || error, error);\n try {\n import('../components/Notifications.js')\n .then(({ showToast }) => showToast(\n `Couldn't ${action} ${collection.replace(/s$/, '')} — ${error?.message || 'database error'}`,\n 'error'))\n .catch(() => {});\n } catch (e) {}\n }\n\n // All jobs an invoice bills: single jobId and/or consolidated jobIds\n _invoiceJobIds(inv) {\n return [inv?.jobId, ...(inv?.jobIds || [])].filter(Boolean);\n }\n\n // Invoice lifecycle → job status sync. Jobs are only marked \"Invoiced\" when their\n // invoice actually leaves Draft (Sent/Paid/etc.) — NOT when a draft is created, so\n // abandoning/deleting a draft never strands jobs in the wrong status.\n _markJobsInvoiced(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (job && job.status !== 'Invoiced') this.update('jobs', jid, { status: 'Invoiced' });\n });\n }\n\n // When an invoice is deleted, revert any of its jobs still marked \"Invoiced\" —\n // unless another invoice also bills that job.\n _revertJobsOnInvoiceDelete(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (!job || job.status !== 'Invoiced') return;\n const billedElsewhere = (this.cache.invoices || []).some(other =>\n other.id !== inv.id && this._invoiceJobIds(other).includes(jid));\n if (!billedElsewhere) this.update('jobs', jid, { status: 'Completed' });\n });\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = this.getNextNumber('LD-', 'leads');\n }\n if (collection === 'notifications' && !item.number) {\n item.number = this.getNextNumber('NT-', 'notifications');\n }\n if (collection === 'invoices' && !item.number) {\n item.number = this.getNextNumber('INV-', 'invoices');\n }\n if (collection === 'quotes' && !item.number) {\n item.number = this.getNextNumber('Q-', 'quotes');\n }\n if (collection === 'jobs' && !item.number) {\n item.number = this.getNextNumber('J-', 'jobs');\n }\n if (collection === 'purchaseOrders' && !item.number) {\n item.number = this.getNextNumber('PO-', 'purchaseOrders');\n }\n if (collection === 'projects' && !item.number) {\n item.number = this.getNextNumber('PRJ-', 'projects');\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Rare path: an invoice created already past Draft marks its jobs immediately\n if (collection === 'invoices' && item.status && item.status !== 'Draft' && item.status !== 'Void') {\n this._markJobsInvoiced(item);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Local Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (this.folderSyncEnabled) {\n if (collection === 'documents' && item.url && item.url.startsWith('data:')) {\n this.writeDocumentFileToFolder(item.id, item.name, item.url).then(relPath => {\n if (relPath) {\n item.url = relPath;\n // Re-save in IndexedDB and rewrite folder collection with updated path\n this.writeRecordToIndexedDB(collection, item);\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n });\n } else {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n }\n this.writeRecordToIndexedDB(collection, item).catch(err => {\n console.error(`Error saving ${collection} to IndexedDB:`, err);\n });\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n item.email = email;\n\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) {\n // Roll back the optimistic add so the UI matches the database\n this.cache[collection] = (this.cache[collection] || []).filter(x => x.id !== item.id);\n this.emit(collection, this.cache[collection]);\n this._notifyWriteError('save', collection, error);\n }\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n // Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n this.writeRecordToIndexedDB(collection, updated).catch(err => {\n console.error(`Error saving updated ${collection} to IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const isAuthUpdate = updates.username !== undefined ||\n updates.email !== undefined ||\n updates.password !== undefined ||\n updates.name !== undefined ||\n updates.role !== undefined ||\n updates.userTypeId !== undefined ||\n updates.color !== undefined ||\n updates.payRate !== undefined;\n\n if (isAuthUpdate) {\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to update user.');\n }\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n const dbPayload = this.denormalizeRecord(updated, 'technicians');\n const { error: dbError } = await supabase\n .from('profiles')\n .update(dbPayload)\n .eq('id', id);\n\n if (dbError) {\n throw dbError;\n }\n\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 2b. Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Roll back to the previous version so the UI matches the database\n const arr = [...(this.cache[collection] || [])];\n const i = arr.findIndex(x => x.id === id);\n if (i !== -1) { arr[i] = previous; this.cache[collection] = arr; this.emit(collection, arr); }\n this._notifyWriteError('update', collection, error);\n }\n });\n }\n\n return updated;\n }\n\n // Child records that must not outlive their parent. Deleting a parent cascades to\n // these (scheduling/alert artifacts). Financial/labor records (invoices, timesheets)\n // are deliberately KEPT — they're history and carry their own denormalised numbers.\n _cascadeDelete(collection, id) {\n const CASCADES = {\n jobs: [ ['schedule', 'jobId'], ['formInstances', 'jobId'], ['notifications', 'jobId'] ],\n assets: [ ['maintenancePlans', 'assetId'], ['notifications', 'assetId'] ],\n formTemplates: [ ['formInstances', 'templateId'] ],\n };\n (CASCADES[collection] || []).forEach(([childCol, fk]) => {\n (this.cache[childCol] || []).filter(c => c[fk] === id).forEach(c => this.delete(childCol, c.id));\n });\n }\n\n delete(collection, id) {\n // 0. Cascade-remove dependent child records BEFORE the parent leaves the cache\n this._cascadeDelete(collection, id);\n\n // 1. Update memory cache (keep a copy of the removed row for rollback)\n const removed = (this.cache[collection] || []).find(item => item.id === id);\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Deleting an invoice frees any jobs it (alone) had marked \"Invoiced\"\n if (collection === 'invoices' && removed) {\n this._revertJobsOnInvoiceDelete(removed);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n this.deleteRecordFromIndexedDB(collection, id).catch(err => {\n console.error(`Error deleting ${id} from ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Restore the row we optimistically removed\n if (removed) { this.cache[collection] = [...(this.cache[collection] || []), removed]; this.emit(collection, this.cache[collection]); }\n this._notifyWriteError('delete', collection, error);\n }\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n /**\n * Generate the next sequential number for a collection.\n * Scans existing records, finds the highest numeric suffix, and returns prefix + (max+1) zero-padded to 5 digits.\n * e.g. getNextNumber('INV-', 'invoices') → 'INV-00001', 'INV-00002', ...\n */\n getNextNumber(defaultPrefix, collection) {\n const settings = this.getSettings();\n const dt = settings.documentTheme || {};\n \n let prefix = defaultPrefix;\n let startingNum = 1;\n\n if (collection === 'invoices') {\n prefix = dt.invoicePrefix !== undefined ? dt.invoicePrefix : 'INV-';\n startingNum = dt.invoiceStartingNumber !== undefined ? parseInt(dt.invoiceStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n } else if (collection === 'quotes') {\n prefix = dt.quotePrefix !== undefined ? dt.quotePrefix : 'Q-';\n startingNum = dt.quoteStartingNumber !== undefined ? parseInt(dt.quoteStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n }\n\n const items = this.getAll(collection) || [];\n let maxNum = startingNum - 1;\n\n items.forEach(item => {\n if (item.number && typeof item.number === 'string' && item.number.startsWith(prefix)) {\n const numStr = item.number.slice(prefix.length);\n const num = parseInt(numStr, 10);\n if (!isNaN(num) && num > maxNum) {\n maxNum = num;\n }\n }\n });\n\n return prefix + (maxNum + 1).toString().padStart(5, '0');\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: defaultLogoLarge,\n logoSmall: defaultLogoSmall,\n markupPercent: 20,\n taxEnabled: true,\n taxRate: 10,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n jobTypes: ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'],\n supplierCategories: ['Electrical', 'Plumbing', 'HVAC', 'Fire Safety', 'Security', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n },\n ai: {\n enabled: (this.companyId && !this.companyId.startsWith('acct_')) ? !!(typeof import.meta.env !== 'undefined' && (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY)) : false,\n apiKey: (this.companyId && !this.companyId.startsWith('acct_')) ? (typeof import.meta.env !== 'undefined' ? (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY || '') : '') : '',\n endpoint: 'https://api.openai.com/v1/chat/completions',\n model: 'gpt-4o-mini',\n systemPrompt: 'You are Relay, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default logos if user has not uploaded custom ones\n if (!merged.logo || merged.logo.includes('logo-large.png')) merged.logo = defaultLogoLarge;\n if (!merged.logoSmall) merged.logoSmall = defaultLogoSmall;\n if (!merged.jobTypes) merged.jobTypes = [...defaultSettings.jobTypes];\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n merged.ai = { ...defaultSettings.ai, ...this.companySettings.ai };\n return merged;\n }\n\n return defaultSettings;\n }\n\n getTaxRate() {\n const settings = this.getSettings();\n if (settings.taxEnabled === false) return 0;\n const rate = settings.taxRate !== undefined ? Number(settings.taxRate) : 10;\n return rate / 100;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('settings'), JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── v1.3 Maps: per-user dispatch start location ────────────────────────────\n // Writes profiles.start_location directly (the technicians update() path goes\n // through the invite-user edge function, which doesn't carry this field).\n // Pass null to clear the override (falls back to the company office).\n async setStartLocation(startLocation) {\n const currentUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null') : null;\n if (!currentUser?.id) throw new Error('Not signed in');\n\n // Update cache first so getStartLocation() sees it immediately\n const techs = [...(this.cache.technicians || [])];\n const i = techs.findIndex(t => t.id === currentUser.id);\n if (i !== -1) {\n techs[i] = { ...techs[i], startLocation };\n this.cache.technicians = techs;\n this.emit('technicians', techs);\n }\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n // Local mode: persist with the rest of the technician record\n await this.writeRecordToIndexedDB?.('technicians', techs[i]).catch?.(() => {});\n return;\n }\n const { error } = await supabase\n .from('profiles')\n .update({ start_location: startLocation })\n .eq('id', currentUser.id);\n if (error) {\n this._notifyWriteError('update', 'start location', error);\n throw error;\n }\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId && !this.companyId.startsWith('acct_')) return true;\n return localStorage.getItem(this.getStorageKey('seeded')) === 'true';\n }\n\n save(collection, items) {\n const prev = this.cache[collection] || [];\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const p = this.writeAllToIndexedDB(collection, items).catch(err => {\n console.error(`Error saving all items to ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, items);\n }\n return p;\n }\n\n // Cloud mode: persist the whole collection. Upsert every current row, then delete\n // any rows that were removed from the array. (Previously this only touched the\n // in-memory cache, so every save() — assign-tech, role edits, form templates,\n // portal edits, stock receiving, mark-read, etc. — silently failed to persist.)\n const table = TABLE_MAP[collection];\n if (!table) return; // cache-only collections (e.g. 'activity') have no table\n\n const payload = items\n .filter(it => it && it.id)\n .map(it => this.denormalizeRecord({ ...it, companyId: this.companyId }, collection));\n if (payload.length) {\n supabase.from(table).upsert(payload).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n\n const currentIds = new Set(items.map(it => it && it.id));\n const removedIds = prev.filter(p => p && p.id && !currentIds.has(p.id)).map(p => p.id);\n if (removedIds.length) {\n supabase.from(table).delete().in('id', removedIds).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n }\n\n markSeeded() {\n localStorage.setItem(this.getStorageKey('seeded'), 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('formTemplates', templates);\n return;\n }\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n async seedDefaultUserTypes() {\n if (!this.companyId) return;\n\n const userTypes = [\n {\n id: `${this.companyId}_ut_admin`,\n name: 'Admin',\n description: 'Full system access',\n permissions: buildGranularPerms(() => true)\n },\n {\n id: `${this.companyId}_ut_manager`,\n name: 'Manager',\n description: 'Can manage most workflows but limited settings access',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company', 'manage_tax'].includes(key);\n return true;\n })\n },\n {\n id: `${this.companyId}_ut_tech`,\n name: 'Technician',\n description: 'Field staff — limited to their own jobs, schedule and timesheets',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n {\n id: `${this.companyId}_ut_office`,\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders', 'Suppliers'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n\n this.cache.userTypes = userTypes;\n this.emit('userTypes', userTypes);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('userTypes', userTypes);\n return;\n }\n\n const dbPayload = userTypes.map(ut => this.denormalizeRecord({ ...ut, companyId: this.companyId }, 'userTypes'));\n const { error } = await supabase.from('user_types').insert(dbPayload);\n if (error) console.error('Error seeding default user types:', error);\n }\n\n async seedDefaultTechnicians() {\n const companyId = this.companyId;\n if (!companyId) return;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const managerTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_manager` : 'ut_manager';\n const techTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_tech` : 'ut_tech';\n const officeTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_office` : 'ut_office';\n\n const defaultTechs = [\n { id: `${companyId}_tech_1`, name: 'Jake Morrow', role: 'Senior Electrician', color: '#3B82F6', userTypeId: adminTypeId, payRate: 95.00, email: 'jake@apexpowerservices.com.au', phone: '0412 233 445', username: 'jake', password: '123456' },\n { id: `${companyId}_tech_2`, name: 'Ryan Holt', role: 'Service Manager', color: '#10B981', userTypeId: managerTypeId, payRate: 85.00, email: 'ryan@apexpowerservices.com.au', phone: '0423 344 556', username: 'ryan', password: '123456' },\n { id: `${companyId}_tech_3`, name: 'Sandra Okafor', role: 'Electrician', color: '#8B5CF6', userTypeId: techTypeId, payRate: 80.00, email: 'sandra@apexpowerservices.com.au', phone: '0434 455 667', username: 'sandra', password: '123456' },\n { id: `${companyId}_tech_4`, name: 'Dean Caruso', role: 'Office Administrator',color: '#F59E0B', userTypeId: officeTypeId, payRate: 50.00, email: 'dean@apexpowerservices.com.au', phone: '0445 566 778', username: 'dean', password: '123456' }\n ];\n\n this.cache.technicians = defaultTechs;\n this.emit('technicians', defaultTechs);\n\n if (companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('technicians', defaultTechs);\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder('technicians', defaultTechs).catch(err => {\n console.error('Error writing seeded technicians to local folder:', err);\n });\n }\n return;\n }\n\n const dbPayload = defaultTechs.map(t => this.denormalizeRecord({ ...t, companyId }, 'technicians'));\n const { error } = await supabase.from('technicians').insert(dbPayload);\n if (error) console.error('Error seeding default technicians:', error);\n }\n\n async migrateLocalToCloud(companyId, adminUserId) {\n this.companyId = companyId;\n this.userId = adminUserId;\n\n // 1. Update the remote company settings with local companySettings\n const currentSettings = this.getSettings();\n const { error: compErr } = await supabase.from('companies').update({\n abn: currentSettings.abn || '',\n phone: currentSettings.phone || '',\n domain: currentSettings.domain || '',\n email: currentSettings.email || '',\n address: currentSettings.address || '',\n settings: currentSettings\n }).eq('id', companyId);\n if (compErr) console.error('Error updating cloud company settings during migration:', compErr);\n\n // 2. Loop through all collections and push to Supabase\n // Skip 'companies' and 'technicians'\n const collectionsToMigrate = Object.keys(TABLE_MAP).filter(col => col !== 'companies' && col !== 'technicians');\n\n for (const col of collectionsToMigrate) {\n const items = this.cache[col] || [];\n if (items.length === 0) continue;\n\n const table = TABLE_MAP[col];\n if (!table) continue;\n\n // Assign the new cloud company_id and denormalize\n const payload = items.map(item => {\n const itemCopy = { ...item, companyId };\n return this.denormalizeRecord(itemCopy, col);\n });\n\n // Write to Supabase using upsert\n const { error } = await supabase.from(table).upsert(payload);\n if (error) {\n console.error(`Error migrating collection ${col}:`, error);\n throw new Error(`Failed to migrate ${col} data: ${error.message}`);\n }\n }\n\n // 3. Clear/close local DB connection and sync from Cloud\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n }\n\n deleteLocalAccountData(accountId) {\n return new Promise((resolve) => {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n if (typeof localStorage !== 'undefined') {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem(`relay_${accountId}_${col}`);\n });\n localStorage.removeItem(`relay_${accountId}_folder_sync_enabled`);\n localStorage.removeItem(`relay_${accountId}_seeded`);\n localStorage.removeItem(`relay_${accountId}_settings`);\n }\n if (typeof window !== 'undefined' && window.indexedDB) {\n const req = window.indexedDB.deleteDatabase(`RelayDispatchDB_${accountId}`);\n req.onsuccess = () => resolve();\n req.onerror = () => resolve();\n req.onblocked = () => {\n console.warn('Database deletion blocked, resolving anyway');\n resolve();\n };\n } else {\n resolve();\n }\n });\n }\n\n async clearAll() {\n this.clearSync();\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n \n if (activeAccount) {\n await this.deleteLocalAccountData(activeAccount);\n } else {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n this.dirHandle = null;\n try {\n await this.clearAllIndexedDB();\n } catch (err) {\n console.error('Error clearing IndexedDB:', err);\n }\n }\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "23773bb37c2c26e6383bdd730d08895b8a6ee620": {"t": 1785754177.7224455, "n": 114577, "h": "e43ab0eb9a29bb2a", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\nconst defaultLogoLarge = new URL('../assets/RELAY_Dispatch_Logo.png', import.meta.url).href;\nconst defaultLogoSmall = new URL('../assets/logo-small.png', import.meta.url).href;\n\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n passwordResetRequests: 'password_reset_requests',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents',\n leads: 'leads',\n schedule: 'schedule',\n projects: 'projects',\n costCenters: 'cost_centers',\n deputyAsks: null\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"deactivated\",\n \"deactivated_at\",\n \"start_location\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"project_id\",\n \"cost_center_id\",\n \"created_at\",\n \"updated_at\"\n ],\n projects: [\n \"id\",\n \"company_id\",\n \"number\",\n \"name\",\n \"customer_id\",\n \"customer_name\",\n \"site_address\",\n \"status\",\n \"description\",\n \"start_date\",\n \"end_date\",\n \"created_at\",\n \"updated_at\"\n ],\n costCenters: [\n \"id\",\n \"company_id\",\n \"name\",\n \"code\",\n \"active\",\n \"xero_sales_account_code\",\n \"xero_expense_account_code\",\n \"xero_tracking_category_name\",\n \"xero_tracking_option_name\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"license_number\",\n \"hourly_rate\",\n \"after_hours_rate\",\n \"callout_fee\",\n \"specialties\",\n \"notes\",\n \"portal_token\",\n \"compliance_docs\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"address\",\n \"category\",\n \"account_number\",\n \"payment_terms\",\n \"notes\",\n \"attachments\",\n \"created_at\",\n \"updated_at\"\n ],\n leads: [\n \"id\",\n \"company_id\",\n \"number\",\n \"title\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"status\",\n \"source\",\n \"value\",\n \"description\",\n \"priority\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"created_at\",\n \"updated_at\"\n ],\n password_reset_requests: [\n \"id\",\n \"technician_id\",\n \"employee_id\",\n \"requested_at\",\n \"status\",\n \"token\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"type\",\n \"description\",\n \"priority\",\n \"read\",\n \"number\",\n \"asset_id\",\n \"job_id\",\n \"due_date\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"type\",\n \"jobid\",\n \"jobnumber\",\n \"technicianid\",\n \"technicianname\",\n \"date\",\n \"starttime\",\n \"finishtime\",\n \"hours\",\n \"starthour\",\n \"endhour\",\n \"notes\",\n \"status\",\n \"taskid\",\n \"taskname\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g><text x=\"90\" y=\"36\" fill=\"#2D3134\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"25\" font-weight=\"900\" letter-spacing=\"0.04em\">DISPATCH</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nconst MODULE_PERMS = {\n 'Dashboard': [{ key: 'view' }],\n 'Customers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_contacts' }],\n 'Leads': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'convert' }],\n 'Quotes': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'approve' }, { key: 'convert' }, { key: 'generate_pdf' }],\n 'Jobs': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_tasks' }, { key: 'book_time' }, { key: 'view_costs' }, { key: 'view_quotes_tab' }, { key: 'view_pos_tab' }, { key: 'view_timesheets_tab' }, { key: 'view_invoices_tab' }, { key: 'manage_materials' }, { key: 'create_invoice' }],\n 'Timesheets': [{ key: 'view_own' }, { key: 'view' }, { key: 'create' }, { key: 'approve' }, { key: 'edit_all' }, { key: 'export' }],\n 'Assets': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Schedule': [{ key: 'view_own' }, { key: 'view' }, { key: 'edit' }],\n 'Contractors': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }],\n 'Suppliers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Stock': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Purchase Orders': [{ key: 'view' }, { key: 'create' }, { key: 'approve' }],\n 'Invoices': [{ key: 'view' }, { key: 'create' }, { key: 'send' }, { key: 'void' }],\n 'Reports': [{ key: 'view' }, { key: 'export' }],\n 'Documents': [{ key: 'view' }, { key: 'upload' }],\n 'Settings': [{ key: 'view' }, { key: 'edit_company' }, { key: 'manage_users' }, { key: 'manage_tax' }]\n};\n\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n this.db = null;\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n this.backupDirHandle = null;\n this.backupDirPermissionGranted = false;\n\n // Check if we have a cloud user at boot (before filling cache)\n const bootUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n const isCloudUser = !!(bootUser && bootUser.companyId && !bootUser.companyId.startsWith('acct_'));\n\n if (!isCloudUser && bootUser && bootUser.companyId && typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', bootUser.companyId);\n }\n\n // Pre-initialize empty arrays for collections.\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Load local settings if they exist (only for offline/demo mode)\n if (!isCloudUser && typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId && !this.companyId.startsWith('acct_')) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n // Only clear sync if we were previously in cloud sync mode or companyId is not defined\n if (!this.companyId || !this.companyId.startsWith('acct_')) {\n this.clearSync();\n }\n }\n });\n\n // Auto-trigger sync or local load if user already logged in at boot\n if (isCloudUser) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n this.initPromise = this.initializeCloudSync();\n } else {\n if (bootUser && bootUser.companyId) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n }\n this.initPromise = this.initializeLocalStore();\n }\n }\n\n getStorageKey(collection) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (activeAccount) {\n return `relay_${activeAccount}_${collection}`;\n }\n return `simpro_${collection}`;\n }\n\n getDBName() {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n return activeAccount ? `RelayDispatchDB_${activeAccount}` : 'RelayDispatchDB';\n }\n\n async initializeUser(user) {\n if (user && user.companyId && !user.companyId.startsWith('acct_')) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n } else {\n this.clearSync();\n if (user && user.companyId) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', user.companyId);\n }\n } else {\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n }\n this.initPromise = this.initializeLocalStore();\n await this.initPromise;\n }\n }\n\n async initializeLocalStore() {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n\n try {\n await this.initIndexedDB();\n } catch (e) {\n console.error('Failed to initialize IndexedDB, falling back to empty arrays:', e);\n }\n\n // Load local settings if they exist (namespaced)\n this.companySettings = null;\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n this.emit('settings', this.getSettings());\n\n // Seed default user types and forms if empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n if (this.cache.technicians.length === 0) {\n await this.seedDefaultTechnicians();\n }\n }\n\n initIndexedDB() {\n return new Promise((resolve, reject) => {\n if (typeof window === 'undefined' || !window.indexedDB) {\n return resolve();\n }\n\n const dbName = this.getDBName();\n const request = window.indexedDB.open(dbName, 4);\n\n request.onerror = (e) => {\n console.error('IndexedDB open error:', e.target.error);\n reject(e.target.error);\n };\n\n request.onsuccess = async (e) => {\n this.db = e.target.result;\n \n // 1. Load custom directory sync config from IndexedDB config store if it exists\n try {\n if (this.db.objectStoreNames.contains('config')) {\n await new Promise((resConfig) => {\n const transaction = this.db.transaction('config', 'readonly');\n const configStore = transaction.objectStore('config');\n \n const getSync = new Promise((resolve) => {\n const req = configStore.get('directory_handle');\n req.onsuccess = () => {\n console.log('[DEBUG Store] getSync onsuccess result:', req.result);\n if (req.result) {\n this.dirHandle = req.result.value;\n console.log('[DEBUG Store] loaded dirHandle:', this.dirHandle ? this.dirHandle.name : null);\n }\n resolve();\n };\n req.onerror = (e) => {\n console.error('[DEBUG Store] getSync onerror:', e);\n resolve();\n };\n });\n\n const getBackup = new Promise((resolve) => {\n const req = configStore.get('backup_directory_handle');\n req.onsuccess = () => {\n if (req.result) this.backupDirHandle = req.result.value;\n resolve();\n };\n req.onerror = () => resolve();\n });\n\n Promise.all([getSync, getBackup]).then(() => resConfig());\n });\n }\n } catch (err) {\n console.warn('Failed to read config from IndexedDB:', err);\n }\n\n // Verify directory permission silently on boot if handle exists\n if (this.dirHandle) {\n try {\n // Check permission silently without prompt dialog\n const opts = { mode: 'readwrite' };\n const status = await this.dirHandle.queryPermission(opts);\n this.folderSyncPermissionGranted = status === 'granted';\n } catch (err) {\n console.warn('Failed to query directory permission on boot:', err);\n }\n }\n\n // Verify backup directory permission silently on boot if handle exists\n if (this.backupDirHandle) {\n try {\n const opts = { mode: 'readwrite' };\n const status = await this.backupDirHandle.queryPermission(opts);\n this.backupDirPermissionGranted = status === 'granted';\n\n // Auto-trigger backup if granted and > 7 days since last backup\n if (this.backupDirPermissionGranted) {\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;\n if (!lastBackup || new Date(lastBackup).getTime() < sevenDaysAgo) {\n this.backupToFolder().catch(err => {\n console.error('Auto background backup failed:', err);\n });\n }\n }\n } catch (err) {\n console.warn('Failed to query backup directory permission on boot:', err);\n }\n }\n\n // Load all collections into cache\n const collections = Object.keys(TABLE_MAP);\n const loadPromises = collections.map(async (col) => {\n try {\n this.cache[col] = await this.readAllFromIndexedDB(col);\n } catch (err) {\n console.error(`Error loading collection ${col} from IndexedDB:`, err);\n this.cache[col] = [];\n }\n });\n\n await Promise.all(loadPromises);\n\n // Perform migration if we have old data in localStorage (only if not in a namespaced local account profile)\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (!activeAccount) {\n const migratePromises = collections.map(async (col) => {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n const items = JSON.parse(localData);\n if (Array.isArray(items) && items.length > 0) {\n // Write all items to IndexedDB\n await this.writeAllToIndexedDB(col, items);\n // Merge into cache if cache is empty\n if (this.cache[col].length === 0) {\n this.cache[col] = items;\n }\n }\n localStorage.removeItem('simpro_' + col);\n } catch (err) {\n console.error(`Migration error for ${col}:`, err);\n }\n }\n });\n\n await Promise.all(migratePromises);\n }\n resolve();\n };\n\n request.onupgradeneeded = (e) => {\n const db = e.target.result;\n const collections = Object.keys(TABLE_MAP);\n collections.forEach(col => {\n if (!db.objectStoreNames.contains(col)) {\n db.createObjectStore(col, { keyPath: 'id' });\n }\n });\n if (!db.objectStoreNames.contains('config')) {\n db.createObjectStore('config', { keyPath: 'key' });\n }\n };\n });\n }\n\n readAllFromIndexedDB(storeName) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve([]);\n try {\n const transaction = this.db.transaction(storeName, 'readonly');\n const store = transaction.objectStore(storeName);\n const getAllRequest = store.getAll();\n\n getAllRequest.onsuccess = () => {\n resolve(getAllRequest.result || []);\n };\n\n getAllRequest.onerror = (e) => {\n reject(e.target.error);\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeRecordToIndexedDB(storeName, record) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const putRequest = store.put(record);\n\n putRequest.onsuccess = () => resolve();\n putRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n deleteRecordFromIndexedDB(storeName, id) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const deleteRequest = store.delete(id);\n\n deleteRequest.onsuccess = () => resolve();\n deleteRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeAllToIndexedDB(storeName, items) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n \n // Clear existing\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n \n clearRequest.onsuccess = () => {\n let count = 0;\n if (items.length === 0) return resolve();\n \n items.forEach(item => {\n const putRequest = store.put(item);\n putRequest.onerror = (e) => reject(e.target.error);\n putRequest.onsuccess = () => {\n count++;\n if (count === items.length) {\n resolve();\n }\n };\n });\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n clearAllIndexedDB() {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const collections = Object.keys(TABLE_MAP);\n const transaction = this.db.transaction(collections, 'readwrite');\n let count = 0;\n \n collections.forEach(col => {\n const store = transaction.objectStore(col);\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n clearRequest.onsuccess = () => {\n count++;\n if (count === collections.length) {\n resolve();\n }\n };\n });\n } catch (err) {\n reject(err);\n }\n });\n }\n\n // ── Local Folder Sync / File System Access Driver ──────────────────────────\n\n async verifyDirPermission(readWrite) {\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n this.folderSyncPermissionGranted = true;\n return true; // Capacitor native has permissions checked at app level\n }\n\n if (!this.dirHandle) return false;\n const opts = {};\n if (readWrite) opts.mode = 'readwrite';\n \n try {\n const currentPerm = await this.dirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.folderSyncPermissionGranted = true;\n return true;\n }\n \n // Request permission (must be called within a user gesture handler)\n const newPerm = await this.dirHandle.requestPermission(opts);\n this.folderSyncPermissionGranted = newPerm === 'granted';\n return this.folderSyncPermissionGranted;\n } catch (e) {\n this.folderSyncPermissionGranted = false;\n return false;\n }\n }\n\n async verifyBackupDirPermission(requestInteractive) {\n if (!this.backupDirHandle) return false;\n const opts = { mode: 'readwrite' };\n try {\n const currentPerm = await this.backupDirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.backupDirPermissionGranted = true;\n return true;\n }\n if (requestInteractive) {\n const newPerm = await this.backupDirHandle.requestPermission(opts);\n this.backupDirPermissionGranted = newPerm === 'granted';\n return this.backupDirPermissionGranted;\n }\n this.backupDirPermissionGranted = false;\n return false;\n } catch (e) {\n this.backupDirPermissionGranted = false;\n return false;\n }\n }\n\n // Ensure a subfolder for the current company exists within the selected directory.\n async ensureCompanyFolderHandle(customDirHandle) {\n const baseHandle = customDirHandle || this.dirHandle;\n if (!baseHandle) return null;\n const rawName = this.companySettings?.name || this.companySettings?.id || 'Company';\n const safeName = rawName.replace(/[\\\\/:*?\"<>|]/g, '_');\n try {\n const companyHandle = await baseHandle.getDirectoryHandle(safeName, { create: true });\n return companyHandle;\n } catch (e) {\n console.error('Failed to get/create company folder:', e);\n return null;\n }\n }\n\n async setBackupDirectory(dirHandle) {\n this.backupDirHandle = dirHandle || null;\n this.backupDirPermissionGranted = !!dirHandle;\n \n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n if (dirHandle) {\n const putReq = configStore.put({ key: 'backup_directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = () => resolve();\n } else {\n const deleteReq = configStore.delete('backup_directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n }\n });\n }\n }\n\n async backupToFolder(dirHandle) {\n const handle = dirHandle || this.backupDirHandle;\n if (!handle) throw new Error('No backup directory configured.');\n \n const currentPerm = await handle.queryPermission({ mode: 'readwrite' });\n if (currentPerm !== 'granted') {\n const newPerm = await handle.requestPermission({ mode: 'readwrite' });\n if (newPerm !== 'granted') {\n throw new Error('Write permission denied for backup directory.');\n }\n }\n \n // Ensure company-specific folder exists within the selected directory\n const companyFolderHandle = await this.ensureCompanyFolderHandle(dirHandle);\n const dataDir = await companyFolderHandle.getDirectoryHandle('data', { create: true });\n const collections = Object.keys(TABLE_MAP);\n for (const col of collections) {\n const items = this.cache[col] || [];\n const fileHandle = await dataDir.getFileHandle(`${col}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items, null, 2));\n await writable.close();\n }\n \n // Update last backup timestamp\n localStorage.setItem('relay_last_backup_time', new Date().toISOString());\n this.backupDirPermissionGranted = true;\n }\n\n async setLocalDirectory(dirHandle) {\n this.dirHandle = dirHandle;\n if (dirHandle) {\n this.folderSyncEnabled = true;\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('folder_sync_enabled'), 'true');\n }\n \n // Save handle to IndexedDB\n if (this.db) {\n await new Promise((resolve, reject) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const putReq = configStore.put({ key: 'directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = (e) => reject(e.target.error);\n });\n }\n \n this.folderSyncPermissionGranted = true;\n \n // Force rewrite all cached collections to directory immediately\n const collections = Object.keys(TABLE_MAP);\n await Promise.all(collections.map(col => this.writeCollectionToFolder(col, this.cache[col])));\n } else {\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n \n // Remove handle from IndexedDB\n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const deleteReq = configStore.delete('directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n });\n }\n }\n }\n\n async writeCollectionToFolder(collection, items) {\n if (!this.folderSyncEnabled) return;\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/data/${collection}.json`,\n data: JSON.stringify(items || [], null, 2),\n directory: 'DOCUMENTS',\n encoding: 'utf8',\n recursive: true\n });\n } catch (err) {\n console.error(`Capacitor failed to write collection ${collection}:`, err);\n }\n return;\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return;\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return;\n const dataDir = await companyFolder.getDirectoryHandle('data', { create: true });\n const fileHandle = await dataDir.getFileHandle(`${collection}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items || [], null, 2));\n await writable.close();\n } catch (err) {\n console.error(`Failed to write collection ${collection} to local directory:`, err);\n }\n }\n\n async writeDocumentFileToFolder(docId, name, dataUrl) {\n if (!this.folderSyncEnabled) return null;\n\n const ext = name.includes('.') ? name.split('.').pop() : 'bin';\n const fileName = `${docId}.${ext}`;\n const relativePath = `documents/${fileName}`;\n\n // Extract base64 payload\n const base64Parts = dataUrl.split(',');\n if (base64Parts.length < 2) return null;\n const base64Data = base64Parts[1];\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/documents/${fileName}`,\n data: base64Data,\n directory: 'DOCUMENTS',\n recursive: true\n });\n return relativePath;\n } catch (err) {\n console.error('Capacitor failed to write document:', err);\n return null;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return null;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return null;\n\n const mimeMatch = base64Parts[0].match(/:(.*?);/);\n const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';\n const binaryStr = atob(base64Data);\n const len = binaryStr.length;\n const bytes = new Uint8Array(len);\n for (let i = 0; i < len; i++) {\n bytes[i] = binaryStr.charCodeAt(i);\n }\n const blob = new Blob([bytes], { type: mime });\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return null;\n const docsDir = await companyFolder.getDirectoryHandle('documents', { create: true });\n const fileHandle = await docsDir.getFileHandle(fileName, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(blob);\n await writable.close();\n\n return relativePath;\n } catch (err) {\n console.error('Failed to save document file to local directory:', err);\n return null;\n }\n }\n\n async getDocumentUrl(doc) {\n if (!doc || !doc.url) return '';\n if (doc.url.startsWith('data:') || doc.url.startsWith('http')) {\n return doc.url;\n }\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n const result = await Filesystem.readFile({\n path: `RelayDispatchData/${doc.url}`,\n directory: 'DOCUMENTS'\n });\n const mime = doc.type || 'application/octet-stream';\n return `data:${mime};base64,${result.data}`;\n } catch (err) {\n console.error('Capacitor failed to read document file:', err);\n throw err;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) {\n throw new Error('Local directory sync is not configured.');\n }\n\n try {\n const hasPerm = await this.verifyDirPermission(false);\n if (!hasPerm) {\n throw new Error('Permission to access the local directory was not granted.');\n }\n\n const parts = doc.url.split('/');\n const dirName = parts[0];\n const fileName = parts[1];\n\n const dir = await this.dirHandle.getDirectoryHandle(dirName);\n const fileHandle = await dir.getFileHandle(fileName);\n const file = await fileHandle.getFile();\n return URL.createObjectURL(file);\n } catch (err) {\n console.error('Failed to read file from local directory:', err);\n throw err;\n }\n }\n\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // Initialize IndexedDB for cloud users too, to store backup directory handles\n await this.initIndexedDB();\n\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id\n query = query.eq('company_id', this.companyId);\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n await this.writeAllToIndexedDB(col, this.cache[col]);\n }\n });\n\n await Promise.all(promises);\n\n // Seed default user types if database is empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n // Unique channel name per subscribe — reusing a fixed name throws\n // \"cannot add postgres_changes callbacks ... after subscribe()\" when sync re-inits.\n const channel = supabase\n .channel(`table-db-changes-${this.companyId || 'anon'}-${Date.now()}`)\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n if (collection === 'contractors') {\n record.businessName = record.name;\n record.active = record.status === 'Active';\n }\n\n if (collection === 'suppliers') {\n record.active = record.status === 'Active';\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.project_id !== undefined) {\n record.projectId = record.project_id;\n delete record.project_id;\n }\n if (record.cost_center_id !== undefined) {\n record.costCenterId = record.cost_center_id;\n delete record.cost_center_id;\n }\n if (record.xero_sales_account_code !== undefined) {\n record.xeroSalesAccountCode = record.xero_sales_account_code;\n delete record.xero_sales_account_code;\n }\n if (record.xero_expense_account_code !== undefined) {\n record.xeroExpenseAccountCode = record.xero_expense_account_code;\n delete record.xero_expense_account_code;\n }\n if (record.xero_tracking_category_name !== undefined) {\n record.xeroTrackingCategoryName = record.xero_tracking_category_name;\n delete record.xero_tracking_category_name;\n }\n if (record.xero_tracking_option_name !== undefined) {\n record.xeroTrackingOptionName = record.xero_tracking_option_name;\n delete record.xero_tracking_option_name;\n }\n if (record.deactivated_at !== undefined) {\n record.deactivatedAt = record.deactivated_at;\n delete record.deactivated_at;\n }\n if (record.start_date !== undefined) {\n record.startDate = record.start_date;\n delete record.start_date;\n }\n if (record.end_date !== undefined) {\n record.endDate = record.end_date;\n delete record.end_date;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (collection === 'schedule') {\n if (record.jobid !== undefined) { record.jobId = record.jobid; delete record.jobid; }\n if (record.jobnumber !== undefined) { record.jobNumber = record.jobnumber; delete record.jobnumber; }\n if (record.technicianid !== undefined) { record.technicianId = record.technicianid; delete record.technicianid; }\n if (record.technicianname !== undefined) { record.technicianName = record.technicianname; delete record.technicianname; }\n if (record.starttime !== undefined) { record.startTime = record.starttime; delete record.starttime; }\n if (record.finishtime !== undefined) { record.finishTime = record.finishtime; delete record.finishtime; }\n if (record.starthour !== undefined) { record.startHour = record.starthour != null ? parseFloat(record.starthour) : null; delete record.starthour; }\n if (record.endhour !== undefined) { record.endHour = record.endhour != null ? parseFloat(record.endhour) : null; delete record.endhour; }\n if (record.taskid !== undefined) { record.taskId = record.taskid; delete record.taskid; }\n if (record.taskname !== undefined) { record.taskName = record.taskname; delete record.taskname; }\n }\n\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined || record.durationHours !== undefined || record.hours !== undefined) {\n const h = parseFloat(record.duration_hours !== undefined ? record.duration_hours : (record.durationHours !== undefined ? record.durationHours : record.hours)) || 0;\n record.durationHours = h;\n record.hours = h;\n delete record.duration_hours;\n }\n if (record.task_name !== undefined) { record.taskName = record.task_name; delete record.task_name; }\n if (record.task_id !== undefined) { record.taskId = record.task_id; delete record.task_id; }\n if (record.task_path !== undefined) { record.taskPath = record.task_path; delete record.task_path; }\n if (record.phase_name !== undefined) { record.phaseName = record.phase_name; delete record.phase_name; }\n if (record.phase_id !== undefined) { record.phaseId = record.phase_id; delete record.phase_id; }\n if (record.start_time !== undefined) { record.startTime = record.start_time; delete record.start_time; }\n if (record.finish_time !== undefined) { record.finishTime = record.finish_time; delete record.finish_time; }\n if (record.approved_by && typeof record.approved_by === 'string' && record.approved_by.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.approved_by.substring(9));\n if (parsed.taskId !== undefined) record.taskId = parsed.taskId;\n if (parsed.taskName !== undefined) record.taskName = parsed.taskName;\n if (parsed.taskPath !== undefined) record.taskPath = parsed.taskPath;\n if (parsed.phaseId !== undefined) record.phaseId = parsed.phaseId;\n if (parsed.phaseName !== undefined) record.phaseName = parsed.phaseName;\n if (parsed.startTime !== undefined) record.startTime = parsed.startTime;\n if (parsed.finishTime !== undefined) record.finishTime = parsed.finishTime;\n if (parsed.description !== undefined) record.description = parsed.description;\n if (parsed.jobNumber !== undefined) record.jobNumber = parsed.jobNumber;\n if (parsed.jobTitle !== undefined) record.jobTitle = parsed.jobTitle;\n if (parsed.hours !== undefined) {\n record.hours = parsed.hours;\n record.durationHours = parsed.hours;\n }\n record.approvedBy = parsed.approvedBy || null;\n } catch (e) {\n console.error(e);\n }\n delete record.approved_by;\n } else if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.license_number !== undefined) { record.licenseNumber = record.license_number; delete record.license_number; }\n if (record.hourly_rate !== undefined) { record.hourlyRate = parseFloat(record.hourly_rate); delete record.hourly_rate; }\n if (record.after_hours_rate !== undefined) { record.afterHoursRate = parseFloat(record.after_hours_rate); delete record.after_hours_rate; }\n if (record.callout_fee !== undefined) { record.calloutFee = parseFloat(record.callout_fee); delete record.callout_fee; }\n if (record.compliance_docs !== undefined) { record.complianceDocs = record.compliance_docs; delete record.compliance_docs; }\n if (record.account_number !== undefined) { record.accountNumber = record.account_number; delete record.account_number; }\n if (record.payment_terms !== undefined) { record.paymentTerms = record.payment_terms; delete record.payment_terms; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.start_location !== undefined) { record.startLocation = record.start_location; delete record.start_location; }\n if (record.day_offset !== undefined) { record.dayOffset = parseInt(record.day_offset); delete record.day_offset; }\n if (record.start_hour !== undefined) { record.startHour = parseFloat(record.start_hour); delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = parseFloat(record.end_hour); delete record.end_hour; }\n\n if (record.color && record.color.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.color.substring(9));\n record.taskId = parsed.taskId;\n record.taskPath = parsed.taskId;\n record.taskName = parsed.taskName;\n record.color = parsed.color;\n if (parsed.date !== undefined) record.date = parsed.date;\n if (parsed.startTime !== undefined) record.startTime = parsed.startTime;\n if (parsed.finishTime !== undefined) record.finishTime = parsed.finishTime;\n if (parsed.hours !== undefined) record.hours = parsed.hours;\n if (parsed.startHour !== undefined && parsed.startHour !== null) record.startHour = parsed.startHour;\n if (parsed.endHour !== undefined && parsed.endHour !== null) record.endHour = parsed.endHour;\n if (parsed.dayOffset !== undefined && parsed.dayOffset !== null) record.dayOffset = parsed.dayOffset;\n } catch (e) {\n console.error(e);\n }\n }\n if (collection === 'invoices') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.invoiceType = 'Standard';\n record.laborProfileId = '';\n record.issueDate = '';\n record.originalQuoteId = '';\n record.originalQuoteNumber = '';\n record.originalSubtotal = 0;\n record.approvedVariationsSum = 0;\n record.pendingVariationsSum = 0;\n record.totalInternalCost = 0;\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.invoiceType = meta.invoiceType || 'Standard';\n record.laborProfileId = meta.laborProfileId || '';\n record.issueDate = meta.issueDate || '';\n record.originalQuoteId = meta.originalQuoteId || '';\n record.originalQuoteNumber = meta.originalQuoteNumber || '';\n record.originalSubtotal = meta.originalSubtotal || 0;\n record.approvedVariationsSum = meta.approvedVariationsSum || 0;\n record.pendingVariationsSum = meta.pendingVariationsSum || 0;\n record.totalInternalCost = meta.totalInternalCost || 0;\n record.jobIds = meta.jobIds || []; // consolidated invoices link multiple jobs\n }\n }\n if (collection === 'quotes') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.laborProfileId = '';\n record.description = '';\n record.isTemplate = false;\n record.templateId = '';\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.laborProfileId = meta.laborProfileId || '';\n record.description = meta.description || '';\n record.isTemplate = meta.isTemplate || false;\n record.templateId = meta.templateId || '';\n }\n }\n if (collection === 'jobs') {\n if (record.notes && record.notes.startsWith('__meta__:')) {\n try {\n const meta = JSON.parse(record.notes.substring(9));\n record.isRecurring = meta.isRecurring || false;\n record.recurringConfig = meta.recurringConfig || null;\n record.preferredTime = meta.preferredTime || '';\n record.description = meta.description || '';\n record.parentJobId = meta.parentJobId || null;\n record.assetName = meta.assetName || '';\n record.notes = meta.notes || '';\n } catch (e) {\n console.error('Error parsing jobs meta:', e);\n }\n }\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (collection === 'contractors') {\n record.name = record.businessName;\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (collection === 'suppliers') {\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.xeroSalesAccountCode !== undefined) {\n record.xero_sales_account_code = record.xeroSalesAccountCode;\n delete record.xeroSalesAccountCode;\n }\n if (record.xeroExpenseAccountCode !== undefined) {\n record.xero_expense_account_code = record.xeroExpenseAccountCode;\n delete record.xeroExpenseAccountCode;\n }\n if (record.xeroTrackingCategoryName !== undefined) {\n record.xero_tracking_category_name = record.xeroTrackingCategoryName;\n delete record.xeroTrackingCategoryName;\n }\n if (record.xeroTrackingOptionName !== undefined) {\n record.xero_tracking_option_name = record.xeroTrackingOptionName;\n delete record.xeroTrackingOptionName;\n }\n if (record.deactivatedAt !== undefined) {\n record.deactivated_at = record.deactivatedAt;\n delete record.deactivatedAt;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.projectId !== undefined) {\n record.project_id = record.projectId;\n delete record.projectId;\n }\n if (record.costCenterId !== undefined) {\n record.cost_center_id = record.costCenterId;\n delete record.costCenterId;\n }\n if (record.startDate !== undefined) {\n record.start_date = record.startDate;\n delete record.startDate;\n }\n if (record.endDate !== undefined) {\n record.end_date = record.endDate;\n delete record.endDate;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined || record.hours !== undefined) {\n const h = parseFloat(record.durationHours !== undefined ? record.durationHours : record.hours) || 0;\n record.duration_hours = h;\n record.durationHours = h;\n record.hours = h;\n }\n if (record.taskName !== undefined) record.task_name = record.taskName;\n if (record.taskId !== undefined) record.task_id = record.taskId;\n if (record.taskPath !== undefined) record.task_path = record.taskPath;\n if (record.phaseName !== undefined) record.phase_name = record.phaseName;\n if (record.phaseId !== undefined) record.phase_id = record.phaseId;\n if (record.startTime !== undefined) record.start_time = record.startTime;\n if (record.finishTime !== undefined) record.finish_time = record.finishTime;\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.licenseNumber !== undefined) { record.license_number = record.licenseNumber; delete record.licenseNumber; }\n if (record.hourlyRate !== undefined) { record.hourly_rate = record.hourlyRate; delete record.hourlyRate; }\n if (record.afterHoursRate !== undefined) { record.after_hours_rate = record.afterHoursRate; delete record.afterHoursRate; }\n if (record.calloutFee !== undefined) { record.callout_fee = record.calloutFee; delete record.calloutFee; }\n if (record.complianceDocs !== undefined) { record.compliance_docs = record.complianceDocs; delete record.complianceDocs; }\n if (record.accountNumber !== undefined) { record.account_number = record.accountNumber; delete record.accountNumber; }\n if (record.paymentTerms !== undefined) { record.payment_terms = record.paymentTerms; delete record.paymentTerms; }\n if (record.jobNumber !== undefined) { record.job_number = record.jobNumber; delete record.jobNumber; }\n if (record.startLocation !== undefined) { record.start_location = record.startLocation; delete record.startLocation; }\n if (record.dayOffset !== undefined) { record.day_offset = record.dayOffset; delete record.dayOffset; }\n if (record.startHour !== undefined) { record.start_hour = record.startHour; delete record.startHour; }\n if (record.endHour !== undefined) { record.end_hour = record.endHour; delete record.endHour; }\n\n if (collection === 'schedule') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || null,\n color: record.color || null,\n date: record.date || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n hours: record.hours || null,\n startHour: record.startHour !== undefined ? record.startHour : null,\n endHour: record.endHour !== undefined ? record.endHour : null,\n dayOffset: record.dayOffset !== undefined ? record.dayOffset : null\n };\n record.color = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'jobs') {\n const meta = {\n isRecurring: record.isRecurring || false,\n recurringConfig: record.recurringConfig || null,\n preferredTime: record.preferredTime || '',\n description: record.description || '',\n parentJobId: record.parentJobId || null,\n assetName: record.assetName || '',\n notes: record.notes || ''\n };\n record.notes = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'invoices') {\n const meta = {\n sections: record.sections || [],\n invoiceType: record.invoiceType || 'Standard',\n laborProfileId: record.laborProfileId || '',\n issueDate: record.issueDate || '',\n originalQuoteId: record.originalQuoteId || '',\n originalQuoteNumber: record.originalQuoteNumber || '',\n originalSubtotal: record.originalSubtotal || 0,\n approvedVariationsSum: record.approvedVariationsSum || 0,\n pendingVariationsSum: record.pendingVariationsSum || 0,\n totalInternalCost: record.totalInternalCost || 0,\n jobIds: record.jobIds || [] // consolidated invoices link multiple jobs\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n if (collection === 'quotes') {\n const meta = {\n sections: record.sections || [],\n laborProfileId: record.laborProfileId || '',\n description: record.description || '',\n isTemplate: record.isTemplate || false,\n templateId: record.templateId || ''\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n if (collection === 'timesheets') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || record.phaseName || null,\n taskPath: record.taskPath || null,\n phaseId: record.phaseId || null,\n phaseName: record.phaseName || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n description: record.description || null,\n hours: record.hours !== undefined ? record.hours : record.duration_hours,\n approvedBy: record.approvedBy || null,\n jobNumber: record.jobNumber || null,\n jobTitle: record.jobTitle || null\n };\n record.approved_by = '__meta__:' + JSON.stringify(meta);\n delete record.approvedBy;\n }\n\n if (collection === 'schedule') {\n record.jobid = record.jobId || null; delete record.jobId;\n record.jobnumber = record.jobNumber || null; delete record.jobNumber;\n record.technicianid = record.technicianId || null; delete record.technicianId;\n record.technicianname = record.technicianName || null; delete record.technicianName;\n record.starttime = record.startTime || null; delete record.startTime;\n record.finishtime = record.finishTime || null; delete record.finishTime;\n record.starthour = record.startHour !== undefined ? record.startHour : null; delete record.startHour;\n record.endhour = record.endHour !== undefined ? record.endHour : null; delete record.endHour;\n record.taskid = record.taskId || null; delete record.taskId;\n record.taskname = record.taskName || null; delete record.taskName;\n }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n const items = this.cache[collection] || [];\n if (collection === 'technicians') {\n const loginMode = typeof localStorage !== 'undefined' ? localStorage.getItem('relay_login_mode') : null;\n if (loginMode === 'local') {\n const currentUserStr = typeof localStorage !== 'undefined' ? localStorage.getItem('currentUser') : null;\n if (currentUserStr) {\n const currentUser = JSON.parse(currentUserStr);\n if (currentUser && currentUser.id) {\n return [{\n id: currentUser.id,\n name: currentUser.name || 'Local Admin',\n role: 'Administrator',\n color: currentUser.color || '#FF5C00',\n userTypeId: currentUser.userTypeId,\n email: currentUser.email || '',\n username: currentUser.username || ''\n }];\n }\n }\n }\n }\n return items;\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Surface a failed background write to the user instead of failing silently.\n // (A silent insert/update failure leaves the optimistic cache entry in place, so the\n // record looks saved until the next reload — the classic \"it didn't save\" symptom.)\n _notifyWriteError(action, collection, error) {\n console.error(`Error trying to ${action} ${collection}:`, error?.message || error, error);\n try {\n import('../components/Notifications.js')\n .then(({ showToast }) => showToast(\n `Couldn't ${action} ${collection.replace(/s$/, '')} — ${error?.message || 'database error'}`,\n 'error'))\n .catch(() => {});\n } catch (e) {}\n }\n\n // All jobs an invoice bills: single jobId and/or consolidated jobIds\n _invoiceJobIds(inv) {\n return [inv?.jobId, ...(inv?.jobIds || [])].filter(Boolean);\n }\n\n // Invoice lifecycle → job status sync. Jobs are only marked \"Invoiced\" when their\n // invoice actually leaves Draft (Sent/Paid/etc.) — NOT when a draft is created, so\n // abandoning/deleting a draft never strands jobs in the wrong status.\n _markJobsInvoiced(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (job && job.status !== 'Invoiced') this.update('jobs', jid, { status: 'Invoiced' });\n });\n }\n\n // When an invoice is deleted, revert any of its jobs still marked \"Invoiced\" —\n // unless another invoice also bills that job.\n _revertJobsOnInvoiceDelete(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (!job || job.status !== 'Invoiced') return;\n const billedElsewhere = (this.cache.invoices || []).some(other =>\n other.id !== inv.id && this._invoiceJobIds(other).includes(jid));\n if (!billedElsewhere) this.update('jobs', jid, { status: 'Completed' });\n });\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = this.getNextNumber('LD-', 'leads');\n }\n if (collection === 'notifications' && !item.number) {\n item.number = this.getNextNumber('NT-', 'notifications');\n }\n if (collection === 'invoices' && !item.number) {\n item.number = this.getNextNumber('INV-', 'invoices');\n }\n if (collection === 'quotes' && !item.number) {\n item.number = this.getNextNumber('Q-', 'quotes');\n }\n if (collection === 'jobs' && !item.number) {\n item.number = this.getNextNumber('J-', 'jobs');\n }\n if (collection === 'purchaseOrders' && !item.number) {\n item.number = this.getNextNumber('PO-', 'purchaseOrders');\n }\n if (collection === 'projects' && !item.number) {\n item.number = this.getNextNumber('PRJ-', 'projects');\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Rare path: an invoice created already past Draft marks its jobs immediately\n if (collection === 'invoices' && item.status && item.status !== 'Draft' && item.status !== 'Void') {\n this._markJobsInvoiced(item);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Local Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (this.folderSyncEnabled) {\n if (collection === 'documents' && item.url && item.url.startsWith('data:')) {\n this.writeDocumentFileToFolder(item.id, item.name, item.url).then(relPath => {\n if (relPath) {\n item.url = relPath;\n // Re-save in IndexedDB and rewrite folder collection with updated path\n this.writeRecordToIndexedDB(collection, item);\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n });\n } else {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n }\n this.writeRecordToIndexedDB(collection, item).catch(err => {\n console.error(`Error saving ${collection} to IndexedDB:`, err);\n });\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n item.email = email;\n\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) {\n // Roll back the optimistic add so the UI matches the database\n this.cache[collection] = (this.cache[collection] || []).filter(x => x.id !== item.id);\n this.emit(collection, this.cache[collection]);\n this._notifyWriteError('save', collection, error);\n }\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n // Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n this.writeRecordToIndexedDB(collection, updated).catch(err => {\n console.error(`Error saving updated ${collection} to IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const isAuthUpdate = updates.username !== undefined ||\n updates.email !== undefined ||\n updates.password !== undefined ||\n updates.name !== undefined ||\n updates.role !== undefined ||\n updates.userTypeId !== undefined ||\n updates.color !== undefined ||\n updates.payRate !== undefined;\n\n if (isAuthUpdate) {\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to update user.');\n }\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n const dbPayload = this.denormalizeRecord(updated, 'technicians');\n const { error: dbError } = await supabase\n .from('profiles')\n .update(dbPayload)\n .eq('id', id);\n\n if (dbError) {\n throw dbError;\n }\n\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 2b. Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Roll back to the previous version so the UI matches the database\n const arr = [...(this.cache[collection] || [])];\n const i = arr.findIndex(x => x.id === id);\n if (i !== -1) { arr[i] = previous; this.cache[collection] = arr; this.emit(collection, arr); }\n this._notifyWriteError('update', collection, error);\n }\n });\n }\n\n return updated;\n }\n\n // Child records that must not outlive their parent. Deleting a parent cascades to\n // these (scheduling/alert artifacts). Financial/labor records (invoices, timesheets)\n // are deliberately KEPT — they're history and carry their own denormalised numbers.\n _cascadeDelete(collection, id) {\n const CASCADES = {\n jobs: [ ['schedule', 'jobId'], ['formInstances', 'jobId'], ['notifications', 'jobId'] ],\n assets: [ ['maintenancePlans', 'assetId'], ['notifications', 'assetId'] ],\n formTemplates: [ ['formInstances', 'templateId'] ],\n };\n (CASCADES[collection] || []).forEach(([childCol, fk]) => {\n (this.cache[childCol] || []).filter(c => c[fk] === id).forEach(c => this.delete(childCol, c.id));\n });\n }\n\n delete(collection, id) {\n // 0. Cascade-remove dependent child records BEFORE the parent leaves the cache\n this._cascadeDelete(collection, id);\n\n // 1. Update memory cache (keep a copy of the removed row for rollback)\n const removed = (this.cache[collection] || []).find(item => item.id === id);\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Deleting an invoice frees any jobs it (alone) had marked \"Invoiced\"\n if (collection === 'invoices' && removed) {\n this._revertJobsOnInvoiceDelete(removed);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n this.deleteRecordFromIndexedDB(collection, id).catch(err => {\n console.error(`Error deleting ${id} from ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Restore the row we optimistically removed\n if (removed) { this.cache[collection] = [...(this.cache[collection] || []), removed]; this.emit(collection, this.cache[collection]); }\n this._notifyWriteError('delete', collection, error);\n }\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n /**\n * Generate the next sequential number for a collection.\n * Scans existing records, finds the highest numeric suffix, and returns prefix + (max+1) zero-padded to 5 digits.\n * e.g. getNextNumber('INV-', 'invoices') → 'INV-00001', 'INV-00002', ...\n */\n getNextNumber(defaultPrefix, collection) {\n const settings = this.getSettings();\n const dt = settings.documentTheme || {};\n \n let prefix = defaultPrefix;\n let startingNum = 1;\n\n if (collection === 'invoices') {\n prefix = dt.invoicePrefix !== undefined ? dt.invoicePrefix : 'INV-';\n startingNum = dt.invoiceStartingNumber !== undefined ? parseInt(dt.invoiceStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n } else if (collection === 'quotes') {\n prefix = dt.quotePrefix !== undefined ? dt.quotePrefix : 'Q-';\n startingNum = dt.quoteStartingNumber !== undefined ? parseInt(dt.quoteStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n }\n\n const items = this.getAll(collection) || [];\n let maxNum = startingNum - 1;\n\n items.forEach(item => {\n if (item.number && typeof item.number === 'string' && item.number.startsWith(prefix)) {\n const numStr = item.number.slice(prefix.length);\n const num = parseInt(numStr, 10);\n if (!isNaN(num) && num > maxNum) {\n maxNum = num;\n }\n }\n });\n\n return prefix + (maxNum + 1).toString().padStart(5, '0');\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: defaultLogoLarge,\n logoSmall: defaultLogoSmall,\n markupPercent: 20,\n taxEnabled: true,\n taxRate: 10,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n jobTypes: ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'],\n supplierCategories: ['Electrical', 'Plumbing', 'HVAC', 'Fire Safety', 'Security', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n },\n ai: {\n enabled: (this.companyId && !this.companyId.startsWith('acct_')) ? !!(typeof import.meta.env !== 'undefined' && (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY)) : false,\n apiKey: (this.companyId && !this.companyId.startsWith('acct_')) ? (typeof import.meta.env !== 'undefined' ? (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY || '') : '') : '',\n endpoint: 'https://api.openai.com/v1/chat/completions',\n model: 'gpt-4o-mini',\n systemPrompt: 'You are Relay, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default logos if user has not uploaded custom ones\n if (!merged.logo || merged.logo.includes('logo-large.png')) merged.logo = defaultLogoLarge;\n if (!merged.logoSmall) merged.logoSmall = defaultLogoSmall;\n if (!merged.jobTypes) merged.jobTypes = [...defaultSettings.jobTypes];\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n merged.ai = { ...defaultSettings.ai, ...this.companySettings.ai };\n return merged;\n }\n\n return defaultSettings;\n }\n\n getTaxRate() {\n const settings = this.getSettings();\n if (settings.taxEnabled === false) return 0;\n const rate = settings.taxRate !== undefined ? Number(settings.taxRate) : 10;\n return rate / 100;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('settings'), JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── v1.3 Maps: per-user dispatch start location ────────────────────────────\n // Writes profiles.start_location directly (the technicians update() path goes\n // through the invite-user edge function, which doesn't carry this field).\n // Pass null to clear the override (falls back to the company office).\n async setStartLocation(startLocation) {\n const currentUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null') : null;\n if (!currentUser?.id) throw new Error('Not signed in');\n\n // Update cache first so getStartLocation() sees it immediately\n const techs = [...(this.cache.technicians || [])];\n const i = techs.findIndex(t => t.id === currentUser.id);\n if (i !== -1) {\n techs[i] = { ...techs[i], startLocation };\n this.cache.technicians = techs;\n this.emit('technicians', techs);\n }\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n // Local mode: persist with the rest of the technician record\n await this.writeRecordToIndexedDB?.('technicians', techs[i]).catch?.(() => {});\n return;\n }\n const { error } = await supabase\n .from('profiles')\n .update({ start_location: startLocation })\n .eq('id', currentUser.id);\n if (error) {\n this._notifyWriteError('update', 'start location', error);\n throw error;\n }\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId && !this.companyId.startsWith('acct_')) return true;\n return localStorage.getItem(this.getStorageKey('seeded')) === 'true';\n }\n\n save(collection, items) {\n const prev = this.cache[collection] || [];\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const p = this.writeAllToIndexedDB(collection, items).catch(err => {\n console.error(`Error saving all items to ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, items);\n }\n return p;\n }\n\n // Cloud mode: persist the whole collection. Upsert every current row, then delete\n // any rows that were removed from the array. (Previously this only touched the\n // in-memory cache, so every save() — assign-tech, role edits, form templates,\n // portal edits, stock receiving, mark-read, etc. — silently failed to persist.)\n const table = TABLE_MAP[collection];\n if (!table) return; // cache-only collections (e.g. 'activity') have no table\n\n const payload = items\n .filter(it => it && it.id)\n .map(it => this.denormalizeRecord({ ...it, companyId: this.companyId }, collection));\n if (payload.length) {\n supabase.from(table).upsert(payload).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n\n const currentIds = new Set(items.map(it => it && it.id));\n const removedIds = prev.filter(p => p && p.id && !currentIds.has(p.id)).map(p => p.id);\n if (removedIds.length) {\n supabase.from(table).delete().in('id', removedIds).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n }\n\n markSeeded() {\n localStorage.setItem(this.getStorageKey('seeded'), 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('formTemplates', templates);\n return;\n }\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n async seedDefaultUserTypes() {\n if (!this.companyId) return;\n\n const userTypes = [\n {\n id: `${this.companyId}_ut_admin`,\n name: 'Admin',\n description: 'Full system access',\n permissions: buildGranularPerms(() => true)\n },\n {\n id: `${this.companyId}_ut_manager`,\n name: 'Manager',\n description: 'Can manage most workflows but limited settings access',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company', 'manage_tax'].includes(key);\n return true;\n })\n },\n {\n id: `${this.companyId}_ut_tech`,\n name: 'Technician',\n description: 'Field staff — limited to their own jobs, schedule and timesheets',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n {\n id: `${this.companyId}_ut_office`,\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders', 'Suppliers'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n\n this.cache.userTypes = userTypes;\n this.emit('userTypes', userTypes);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('userTypes', userTypes);\n return;\n }\n\n const dbPayload = userTypes.map(ut => this.denormalizeRecord({ ...ut, companyId: this.companyId }, 'userTypes'));\n const { error } = await supabase.from('user_types').insert(dbPayload);\n if (error) console.error('Error seeding default user types:', error);\n }\n\n async seedDefaultTechnicians() {\n const companyId = this.companyId;\n if (!companyId) return;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const managerTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_manager` : 'ut_manager';\n const techTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_tech` : 'ut_tech';\n const officeTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_office` : 'ut_office';\n\n const defaultTechs = [\n { id: `${companyId}_tech_1`, name: 'Jake Morrow', role: 'Senior Electrician', color: '#3B82F6', userTypeId: adminTypeId, payRate: 95.00, email: 'jake@apexpowerservices.com.au', phone: '0412 233 445', username: 'jake', password: '123456' },\n { id: `${companyId}_tech_2`, name: 'Ryan Holt', role: 'Service Manager', color: '#10B981', userTypeId: managerTypeId, payRate: 85.00, email: 'ryan@apexpowerservices.com.au', phone: '0423 344 556', username: 'ryan', password: '123456' },\n { id: `${companyId}_tech_3`, name: 'Sandra Okafor', role: 'Electrician', color: '#8B5CF6', userTypeId: techTypeId, payRate: 80.00, email: 'sandra@apexpowerservices.com.au', phone: '0434 455 667', username: 'sandra', password: '123456' },\n { id: `${companyId}_tech_4`, name: 'Dean Caruso', role: 'Office Administrator',color: '#F59E0B', userTypeId: officeTypeId, payRate: 50.00, email: 'dean@apexpowerservices.com.au', phone: '0445 566 778', username: 'dean', password: '123456' }\n ];\n\n this.cache.technicians = defaultTechs;\n this.emit('technicians', defaultTechs);\n\n if (companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('technicians', defaultTechs);\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder('technicians', defaultTechs).catch(err => {\n console.error('Error writing seeded technicians to local folder:', err);\n });\n }\n return;\n }\n\n const dbPayload = defaultTechs.map(t => this.denormalizeRecord({ ...t, companyId }, 'technicians'));\n const { error } = await supabase.from('technicians').insert(dbPayload);\n if (error) console.error('Error seeding default technicians:', error);\n }\n\n async migrateLocalToCloud(companyId, adminUserId) {\n this.companyId = companyId;\n this.userId = adminUserId;\n\n // 1. Update the remote company settings with local companySettings\n const currentSettings = this.getSettings();\n const { error: compErr } = await supabase.from('companies').update({\n abn: currentSettings.abn || '',\n phone: currentSettings.phone || '',\n domain: currentSettings.domain || '',\n email: currentSettings.email || '',\n address: currentSettings.address || '',\n settings: currentSettings\n }).eq('id', companyId);\n if (compErr) console.error('Error updating cloud company settings during migration:', compErr);\n\n // 2. Loop through all collections and push to Supabase\n // Skip 'companies' and 'technicians'\n const collectionsToMigrate = Object.keys(TABLE_MAP).filter(col => col !== 'companies' && col !== 'technicians');\n\n for (const col of collectionsToMigrate) {\n const items = this.cache[col] || [];\n if (items.length === 0) continue;\n\n const table = TABLE_MAP[col];\n if (!table) continue;\n\n // Assign the new cloud company_id and denormalize\n const payload = items.map(item => {\n const itemCopy = { ...item, companyId };\n return this.denormalizeRecord(itemCopy, col);\n });\n\n // Write to Supabase using upsert\n const { error } = await supabase.from(table).upsert(payload);\n if (error) {\n console.error(`Error migrating collection ${col}:`, error);\n throw new Error(`Failed to migrate ${col} data: ${error.message}`);\n }\n }\n\n // 3. Clear/close local DB connection and sync from Cloud\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n }\n\n deleteLocalAccountData(accountId) {\n return new Promise((resolve) => {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n if (typeof localStorage !== 'undefined') {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem(`relay_${accountId}_${col}`);\n });\n localStorage.removeItem(`relay_${accountId}_folder_sync_enabled`);\n localStorage.removeItem(`relay_${accountId}_seeded`);\n localStorage.removeItem(`relay_${accountId}_settings`);\n }\n if (typeof window !== 'undefined' && window.indexedDB) {\n const req = window.indexedDB.deleteDatabase(`RelayDispatchDB_${accountId}`);\n req.onsuccess = () => resolve();\n req.onerror = () => resolve();\n req.onblocked = () => {\n console.warn('Database deletion blocked, resolving anyway');\n resolve();\n };\n } else {\n resolve();\n }\n });\n }\n\n async clearAll() {\n this.clearSync();\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n \n if (activeAccount) {\n await this.deleteLocalAccountData(activeAccount);\n } else {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n this.dirHandle = null;\n try {\n await this.clearAllIndexedDB();\n } catch (err) {\n console.error('Error clearing IndexedDB:', err);\n }\n }\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "040b9ce8c025398334b21048660f2d999383c8d0": {"t": 1785754179.4384766, "n": 112189, "h": "1799e4be01b55a91", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\nconst defaultLogoLarge = new URL('../assets/RELAY_Dispatch_Logo.png', import.meta.url).href;\nconst defaultLogoSmall = new URL('../assets/logo-small.png', import.meta.url).href;\n\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n passwordResetRequests: 'password_reset_requests',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents',\n leads: 'leads',\n schedule: 'schedule',\n projects: 'projects',\n costCenters: 'cost_centers',\n deputyAsks: null\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"deactivated\",\n \"deactivated_at\",\n \"start_location\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"project_id\",\n \"cost_center_id\",\n \"created_at\",\n \"updated_at\"\n ],\n projects: [\n \"id\",\n \"company_id\",\n \"number\",\n \"name\",\n \"customer_id\",\n \"customer_name\",\n \"site_address\",\n \"status\",\n \"description\",\n \"start_date\",\n \"end_date\",\n \"created_at\",\n \"updated_at\"\n ],\n costCenters: [\n \"id\",\n \"company_id\",\n \"name\",\n \"code\",\n \"active\",\n \"xero_sales_account_code\",\n \"xero_expense_account_code\",\n \"xero_tracking_category_name\",\n \"xero_tracking_option_name\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"license_number\",\n \"hourly_rate\",\n \"after_hours_rate\",\n \"callout_fee\",\n \"specialties\",\n \"notes\",\n \"portal_token\",\n \"compliance_docs\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"address\",\n \"category\",\n \"account_number\",\n \"payment_terms\",\n \"notes\",\n \"attachments\",\n \"created_at\",\n \"updated_at\"\n ],\n leads: [\n \"id\",\n \"company_id\",\n \"number\",\n \"title\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"status\",\n \"source\",\n \"value\",\n \"description\",\n \"priority\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"created_at\",\n \"updated_at\"\n ],\n password_reset_requests: [\n \"id\",\n \"technician_id\",\n \"employee_id\",\n \"requested_at\",\n \"status\",\n \"token\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"type\",\n \"description\",\n \"priority\",\n \"read\",\n \"number\",\n \"asset_id\",\n \"job_id\",\n \"due_date\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g><text x=\"90\" y=\"36\" fill=\"#2D3134\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"25\" font-weight=\"900\" letter-spacing=\"0.04em\">DISPATCH</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nconst MODULE_PERMS = {\n 'Dashboard': [{ key: 'view' }],\n 'Customers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_contacts' }],\n 'Leads': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'convert' }],\n 'Quotes': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'approve' }, { key: 'convert' }, { key: 'generate_pdf' }],\n 'Jobs': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_tasks' }, { key: 'book_time' }, { key: 'view_costs' }, { key: 'view_quotes_tab' }, { key: 'view_pos_tab' }, { key: 'view_timesheets_tab' }, { key: 'view_invoices_tab' }, { key: 'manage_materials' }, { key: 'create_invoice' }],\n 'Timesheets': [{ key: 'view_own' }, { key: 'view' }, { key: 'create' }, { key: 'approve' }, { key: 'edit_all' }, { key: 'export' }],\n 'Assets': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Schedule': [{ key: 'view_own' }, { key: 'view' }, { key: 'edit' }],\n 'Contractors': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }],\n 'Suppliers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Stock': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Purchase Orders': [{ key: 'view' }, { key: 'create' }, { key: 'approve' }],\n 'Invoices': [{ key: 'view' }, { key: 'create' }, { key: 'send' }, { key: 'void' }],\n 'Reports': [{ key: 'view' }, { key: 'export' }],\n 'Documents': [{ key: 'view' }, { key: 'upload' }],\n 'Settings': [{ key: 'view' }, { key: 'edit_company' }, { key: 'manage_users' }, { key: 'manage_tax' }]\n};\n\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n this.db = null;\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n this.backupDirHandle = null;\n this.backupDirPermissionGranted = false;\n\n // Check if we have a cloud user at boot (before filling cache)\n const bootUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n const isCloudUser = !!(bootUser && bootUser.companyId && !bootUser.companyId.startsWith('acct_'));\n\n if (!isCloudUser && bootUser && bootUser.companyId && typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', bootUser.companyId);\n }\n\n // Pre-initialize empty arrays for collections.\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Load local settings if they exist (only for offline/demo mode)\n if (!isCloudUser && typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId && !this.companyId.startsWith('acct_')) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n // Only clear sync if we were previously in cloud sync mode or companyId is not defined\n if (!this.companyId || !this.companyId.startsWith('acct_')) {\n this.clearSync();\n }\n }\n });\n\n // Auto-trigger sync or local load if user already logged in at boot\n if (isCloudUser) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n this.initPromise = this.initializeCloudSync();\n } else {\n if (bootUser && bootUser.companyId) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n }\n this.initPromise = this.initializeLocalStore();\n }\n }\n\n getStorageKey(collection) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (activeAccount) {\n return `relay_${activeAccount}_${collection}`;\n }\n return `simpro_${collection}`;\n }\n\n getDBName() {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n return activeAccount ? `RelayDispatchDB_${activeAccount}` : 'RelayDispatchDB';\n }\n\n async initializeUser(user) {\n if (user && user.companyId && !user.companyId.startsWith('acct_')) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n } else {\n this.clearSync();\n if (user && user.companyId) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', user.companyId);\n }\n } else {\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n }\n this.initPromise = this.initializeLocalStore();\n await this.initPromise;\n }\n }\n\n async initializeLocalStore() {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n\n try {\n await this.initIndexedDB();\n } catch (e) {\n console.error('Failed to initialize IndexedDB, falling back to empty arrays:', e);\n }\n\n // Load local settings if they exist (namespaced)\n this.companySettings = null;\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n this.emit('settings', this.getSettings());\n\n // Seed default user types and forms if empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n if (this.cache.technicians.length === 0) {\n await this.seedDefaultTechnicians();\n }\n }\n\n initIndexedDB() {\n return new Promise((resolve, reject) => {\n if (typeof window === 'undefined' || !window.indexedDB) {\n return resolve();\n }\n\n const dbName = this.getDBName();\n const request = window.indexedDB.open(dbName, 3);\n\n request.onerror = (e) => {\n console.error('IndexedDB open error:', e.target.error);\n reject(e.target.error);\n };\n\n request.onsuccess = async (e) => {\n this.db = e.target.result;\n \n // 1. Load custom directory sync config from IndexedDB config store if it exists\n try {\n if (this.db.objectStoreNames.contains('config')) {\n await new Promise((resConfig) => {\n const transaction = this.db.transaction('config', 'readonly');\n const configStore = transaction.objectStore('config');\n \n const getSync = new Promise((resolve) => {\n const req = configStore.get('directory_handle');\n req.onsuccess = () => {\n console.log('[DEBUG Store] getSync onsuccess result:', req.result);\n if (req.result) {\n this.dirHandle = req.result.value;\n console.log('[DEBUG Store] loaded dirHandle:', this.dirHandle ? this.dirHandle.name : null);\n }\n resolve();\n };\n req.onerror = (e) => {\n console.error('[DEBUG Store] getSync onerror:', e);\n resolve();\n };\n });\n\n const getBackup = new Promise((resolve) => {\n const req = configStore.get('backup_directory_handle');\n req.onsuccess = () => {\n if (req.result) this.backupDirHandle = req.result.value;\n resolve();\n };\n req.onerror = () => resolve();\n });\n\n Promise.all([getSync, getBackup]).then(() => resConfig());\n });\n }\n } catch (err) {\n console.warn('Failed to read config from IndexedDB:', err);\n }\n\n // Verify directory permission silently on boot if handle exists\n if (this.dirHandle) {\n try {\n // Check permission silently without prompt dialog\n const opts = { mode: 'readwrite' };\n const status = await this.dirHandle.queryPermission(opts);\n this.folderSyncPermissionGranted = status === 'granted';\n } catch (err) {\n console.warn('Failed to query directory permission on boot:', err);\n }\n }\n\n // Verify backup directory permission silently on boot if handle exists\n if (this.backupDirHandle) {\n try {\n const opts = { mode: 'readwrite' };\n const status = await this.backupDirHandle.queryPermission(opts);\n this.backupDirPermissionGranted = status === 'granted';\n\n // Auto-trigger backup if granted and > 7 days since last backup\n if (this.backupDirPermissionGranted) {\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;\n if (!lastBackup || new Date(lastBackup).getTime() < sevenDaysAgo) {\n this.backupToFolder().catch(err => {\n console.error('Auto background backup failed:', err);\n });\n }\n }\n } catch (err) {\n console.warn('Failed to query backup directory permission on boot:', err);\n }\n }\n\n // Load all collections into cache\n const collections = Object.keys(TABLE_MAP);\n const loadPromises = collections.map(async (col) => {\n try {\n this.cache[col] = await this.readAllFromIndexedDB(col);\n } catch (err) {\n console.error(`Error loading collection ${col} from IndexedDB:`, err);\n this.cache[col] = [];\n }\n });\n\n await Promise.all(loadPromises);\n\n // Perform migration if we have old data in localStorage (only if not in a namespaced local account profile)\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (!activeAccount) {\n const migratePromises = collections.map(async (col) => {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n const items = JSON.parse(localData);\n if (Array.isArray(items) && items.length > 0) {\n // Write all items to IndexedDB\n await this.writeAllToIndexedDB(col, items);\n // Merge into cache if cache is empty\n if (this.cache[col].length === 0) {\n this.cache[col] = items;\n }\n }\n localStorage.removeItem('simpro_' + col);\n } catch (err) {\n console.error(`Migration error for ${col}:`, err);\n }\n }\n });\n\n await Promise.all(migratePromises);\n }\n resolve();\n };\n\n request.onupgradeneeded = (e) => {\n const db = e.target.result;\n const collections = Object.keys(TABLE_MAP);\n collections.forEach(col => {\n if (!db.objectStoreNames.contains(col)) {\n db.createObjectStore(col, { keyPath: 'id' });\n }\n });\n if (!db.objectStoreNames.contains('config')) {\n db.createObjectStore('config', { keyPath: 'key' });\n }\n };\n });\n }\n\n readAllFromIndexedDB(storeName) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve([]);\n try {\n const transaction = this.db.transaction(storeName, 'readonly');\n const store = transaction.objectStore(storeName);\n const getAllRequest = store.getAll();\n\n getAllRequest.onsuccess = () => {\n resolve(getAllRequest.result || []);\n };\n\n getAllRequest.onerror = (e) => {\n reject(e.target.error);\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeRecordToIndexedDB(storeName, record) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const putRequest = store.put(record);\n\n putRequest.onsuccess = () => resolve();\n putRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n deleteRecordFromIndexedDB(storeName, id) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const deleteRequest = store.delete(id);\n\n deleteRequest.onsuccess = () => resolve();\n deleteRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeAllToIndexedDB(storeName, items) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n \n // Clear existing\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n \n clearRequest.onsuccess = () => {\n let count = 0;\n if (items.length === 0) return resolve();\n \n items.forEach(item => {\n const putRequest = store.put(item);\n putRequest.onerror = (e) => reject(e.target.error);\n putRequest.onsuccess = () => {\n count++;\n if (count === items.length) {\n resolve();\n }\n };\n });\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n clearAllIndexedDB() {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const collections = Object.keys(TABLE_MAP);\n const transaction = this.db.transaction(collections, 'readwrite');\n let count = 0;\n \n collections.forEach(col => {\n const store = transaction.objectStore(col);\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n clearRequest.onsuccess = () => {\n count++;\n if (count === collections.length) {\n resolve();\n }\n };\n });\n } catch (err) {\n reject(err);\n }\n });\n }\n\n // ── Local Folder Sync / File System Access Driver ──────────────────────────\n\n async verifyDirPermission(readWrite) {\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n this.folderSyncPermissionGranted = true;\n return true; // Capacitor native has permissions checked at app level\n }\n\n if (!this.dirHandle) return false;\n const opts = {};\n if (readWrite) opts.mode = 'readwrite';\n \n try {\n const currentPerm = await this.dirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.folderSyncPermissionGranted = true;\n return true;\n }\n \n // Request permission (must be called within a user gesture handler)\n const newPerm = await this.dirHandle.requestPermission(opts);\n this.folderSyncPermissionGranted = newPerm === 'granted';\n return this.folderSyncPermissionGranted;\n } catch (e) {\n this.folderSyncPermissionGranted = false;\n return false;\n }\n }\n\n async verifyBackupDirPermission(requestInteractive) {\n if (!this.backupDirHandle) return false;\n const opts = { mode: 'readwrite' };\n try {\n const currentPerm = await this.backupDirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.backupDirPermissionGranted = true;\n return true;\n }\n if (requestInteractive) {\n const newPerm = await this.backupDirHandle.requestPermission(opts);\n this.backupDirPermissionGranted = newPerm === 'granted';\n return this.backupDirPermissionGranted;\n }\n this.backupDirPermissionGranted = false;\n return false;\n } catch (e) {\n this.backupDirPermissionGranted = false;\n return false;\n }\n }\n\n // Ensure a subfolder for the current company exists within the selected directory.\n async ensureCompanyFolderHandle(customDirHandle) {\n const baseHandle = customDirHandle || this.dirHandle;\n if (!baseHandle) return null;\n const rawName = this.companySettings?.name || this.companySettings?.id || 'Company';\n const safeName = rawName.replace(/[\\\\/:*?\"<>|]/g, '_');\n try {\n const companyHandle = await baseHandle.getDirectoryHandle(safeName, { create: true });\n return companyHandle;\n } catch (e) {\n console.error('Failed to get/create company folder:', e);\n return null;\n }\n }\n\n async setBackupDirectory(dirHandle) {\n this.backupDirHandle = dirHandle || null;\n this.backupDirPermissionGranted = !!dirHandle;\n \n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n if (dirHandle) {\n const putReq = configStore.put({ key: 'backup_directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = () => resolve();\n } else {\n const deleteReq = configStore.delete('backup_directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n }\n });\n }\n }\n\n async backupToFolder(dirHandle) {\n const handle = dirHandle || this.backupDirHandle;\n if (!handle) throw new Error('No backup directory configured.');\n \n const currentPerm = await handle.queryPermission({ mode: 'readwrite' });\n if (currentPerm !== 'granted') {\n const newPerm = await handle.requestPermission({ mode: 'readwrite' });\n if (newPerm !== 'granted') {\n throw new Error('Write permission denied for backup directory.');\n }\n }\n \n // Ensure company-specific folder exists within the selected directory\n const companyFolderHandle = await this.ensureCompanyFolderHandle(dirHandle);\n const dataDir = await companyFolderHandle.getDirectoryHandle('data', { create: true });\n const collections = Object.keys(TABLE_MAP);\n for (const col of collections) {\n const items = this.cache[col] || [];\n const fileHandle = await dataDir.getFileHandle(`${col}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items, null, 2));\n await writable.close();\n }\n \n // Update last backup timestamp\n localStorage.setItem('relay_last_backup_time', new Date().toISOString());\n this.backupDirPermissionGranted = true;\n }\n\n async setLocalDirectory(dirHandle) {\n this.dirHandle = dirHandle;\n if (dirHandle) {\n this.folderSyncEnabled = true;\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('folder_sync_enabled'), 'true');\n }\n \n // Save handle to IndexedDB\n if (this.db) {\n await new Promise((resolve, reject) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const putReq = configStore.put({ key: 'directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = (e) => reject(e.target.error);\n });\n }\n \n this.folderSyncPermissionGranted = true;\n \n // Force rewrite all cached collections to directory immediately\n const collections = Object.keys(TABLE_MAP);\n await Promise.all(collections.map(col => this.writeCollectionToFolder(col, this.cache[col])));\n } else {\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n \n // Remove handle from IndexedDB\n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const deleteReq = configStore.delete('directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n });\n }\n }\n }\n\n async writeCollectionToFolder(collection, items) {\n if (!this.folderSyncEnabled) return;\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/data/${collection}.json`,\n data: JSON.stringify(items || [], null, 2),\n directory: 'DOCUMENTS',\n encoding: 'utf8',\n recursive: true\n });\n } catch (err) {\n console.error(`Capacitor failed to write collection ${collection}:`, err);\n }\n return;\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return;\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return;\n const dataDir = await companyFolder.getDirectoryHandle('data', { create: true });\n const fileHandle = await dataDir.getFileHandle(`${collection}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items || [], null, 2));\n await writable.close();\n } catch (err) {\n console.error(`Failed to write collection ${collection} to local directory:`, err);\n }\n }\n\n async writeDocumentFileToFolder(docId, name, dataUrl) {\n if (!this.folderSyncEnabled) return null;\n\n const ext = name.includes('.') ? name.split('.').pop() : 'bin';\n const fileName = `${docId}.${ext}`;\n const relativePath = `documents/${fileName}`;\n\n // Extract base64 payload\n const base64Parts = dataUrl.split(',');\n if (base64Parts.length < 2) return null;\n const base64Data = base64Parts[1];\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/documents/${fileName}`,\n data: base64Data,\n directory: 'DOCUMENTS',\n recursive: true\n });\n return relativePath;\n } catch (err) {\n console.error('Capacitor failed to write document:', err);\n return null;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return null;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return null;\n\n const mimeMatch = base64Parts[0].match(/:(.*?);/);\n const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';\n const binaryStr = atob(base64Data);\n const len = binaryStr.length;\n const bytes = new Uint8Array(len);\n for (let i = 0; i < len; i++) {\n bytes[i] = binaryStr.charCodeAt(i);\n }\n const blob = new Blob([bytes], { type: mime });\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return null;\n const docsDir = await companyFolder.getDirectoryHandle('documents', { create: true });\n const fileHandle = await docsDir.getFileHandle(fileName, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(blob);\n await writable.close();\n\n return relativePath;\n } catch (err) {\n console.error('Failed to save document file to local directory:', err);\n return null;\n }\n }\n\n async getDocumentUrl(doc) {\n if (!doc || !doc.url) return '';\n if (doc.url.startsWith('data:') || doc.url.startsWith('http')) {\n return doc.url;\n }\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n const result = await Filesystem.readFile({\n path: `RelayDispatchData/${doc.url}`,\n directory: 'DOCUMENTS'\n });\n const mime = doc.type || 'application/octet-stream';\n return `data:${mime};base64,${result.data}`;\n } catch (err) {\n console.error('Capacitor failed to read document file:', err);\n throw err;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) {\n throw new Error('Local directory sync is not configured.');\n }\n\n try {\n const hasPerm = await this.verifyDirPermission(false);\n if (!hasPerm) {\n throw new Error('Permission to access the local directory was not granted.');\n }\n\n const parts = doc.url.split('/');\n const dirName = parts[0];\n const fileName = parts[1];\n\n const dir = await this.dirHandle.getDirectoryHandle(dirName);\n const fileHandle = await dir.getFileHandle(fileName);\n const file = await fileHandle.getFile();\n return URL.createObjectURL(file);\n } catch (err) {\n console.error('Failed to read file from local directory:', err);\n throw err;\n }\n }\n\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // Initialize IndexedDB for cloud users too, to store backup directory handles\n await this.initIndexedDB();\n\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id\n query = query.eq('company_id', this.companyId);\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n await this.writeAllToIndexedDB(col, this.cache[col]);\n }\n });\n\n await Promise.all(promises);\n\n // Seed default user types if database is empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n // Unique channel name per subscribe — reusing a fixed name throws\n // \"cannot add postgres_changes callbacks ... after subscribe()\" when sync re-inits.\n const channel = supabase\n .channel(`table-db-changes-${this.companyId || 'anon'}-${Date.now()}`)\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n if (collection === 'contractors') {\n record.businessName = record.name;\n record.active = record.status === 'Active';\n }\n\n if (collection === 'suppliers') {\n record.active = record.status === 'Active';\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.project_id !== undefined) {\n record.projectId = record.project_id;\n delete record.project_id;\n }\n if (record.cost_center_id !== undefined) {\n record.costCenterId = record.cost_center_id;\n delete record.cost_center_id;\n }\n if (record.xero_sales_account_code !== undefined) {\n record.xeroSalesAccountCode = record.xero_sales_account_code;\n delete record.xero_sales_account_code;\n }\n if (record.xero_expense_account_code !== undefined) {\n record.xeroExpenseAccountCode = record.xero_expense_account_code;\n delete record.xero_expense_account_code;\n }\n if (record.xero_tracking_category_name !== undefined) {\n record.xeroTrackingCategoryName = record.xero_tracking_category_name;\n delete record.xero_tracking_category_name;\n }\n if (record.xero_tracking_option_name !== undefined) {\n record.xeroTrackingOptionName = record.xero_tracking_option_name;\n delete record.xero_tracking_option_name;\n }\n if (record.deactivated_at !== undefined) {\n record.deactivatedAt = record.deactivated_at;\n delete record.deactivated_at;\n }\n if (record.start_date !== undefined) {\n record.startDate = record.start_date;\n delete record.start_date;\n }\n if (record.end_date !== undefined) {\n record.endDate = record.end_date;\n delete record.end_date;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined || record.durationHours !== undefined || record.hours !== undefined) {\n const h = parseFloat(record.duration_hours !== undefined ? record.duration_hours : (record.durationHours !== undefined ? record.durationHours : record.hours)) || 0;\n record.durationHours = h;\n record.hours = h;\n delete record.duration_hours;\n }\n if (record.task_name !== undefined) { record.taskName = record.task_name; delete record.task_name; }\n if (record.task_id !== undefined) { record.taskId = record.task_id; delete record.task_id; }\n if (record.task_path !== undefined) { record.taskPath = record.task_path; delete record.task_path; }\n if (record.phase_name !== undefined) { record.phaseName = record.phase_name; delete record.phase_name; }\n if (record.phase_id !== undefined) { record.phaseId = record.phase_id; delete record.phase_id; }\n if (record.start_time !== undefined) { record.startTime = record.start_time; delete record.start_time; }\n if (record.finish_time !== undefined) { record.finishTime = record.finish_time; delete record.finish_time; }\n if (record.approved_by && typeof record.approved_by === 'string' && record.approved_by.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.approved_by.substring(9));\n if (parsed.taskId !== undefined) record.taskId = parsed.taskId;\n if (parsed.taskName !== undefined) record.taskName = parsed.taskName;\n if (parsed.taskPath !== undefined) record.taskPath = parsed.taskPath;\n if (parsed.phaseId !== undefined) record.phaseId = parsed.phaseId;\n if (parsed.phaseName !== undefined) record.phaseName = parsed.phaseName;\n if (parsed.startTime !== undefined) record.startTime = parsed.startTime;\n if (parsed.finishTime !== undefined) record.finishTime = parsed.finishTime;\n if (parsed.description !== undefined) record.description = parsed.description;\n if (parsed.jobNumber !== undefined) record.jobNumber = parsed.jobNumber;\n if (parsed.jobTitle !== undefined) record.jobTitle = parsed.jobTitle;\n if (parsed.hours !== undefined) {\n record.hours = parsed.hours;\n record.durationHours = parsed.hours;\n }\n record.approvedBy = parsed.approvedBy || null;\n } catch (e) {\n console.error(e);\n }\n delete record.approved_by;\n } else if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.license_number !== undefined) { record.licenseNumber = record.license_number; delete record.license_number; }\n if (record.hourly_rate !== undefined) { record.hourlyRate = parseFloat(record.hourly_rate); delete record.hourly_rate; }\n if (record.after_hours_rate !== undefined) { record.afterHoursRate = parseFloat(record.after_hours_rate); delete record.after_hours_rate; }\n if (record.callout_fee !== undefined) { record.calloutFee = parseFloat(record.callout_fee); delete record.callout_fee; }\n if (record.compliance_docs !== undefined) { record.complianceDocs = record.compliance_docs; delete record.compliance_docs; }\n if (record.account_number !== undefined) { record.accountNumber = record.account_number; delete record.account_number; }\n if (record.payment_terms !== undefined) { record.paymentTerms = record.payment_terms; delete record.payment_terms; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.start_location !== undefined) { record.startLocation = record.start_location; delete record.start_location; }\n if (record.day_offset !== undefined) { record.dayOffset = parseInt(record.day_offset); delete record.day_offset; }\n if (record.start_hour !== undefined) { record.startHour = parseFloat(record.start_hour); delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = parseFloat(record.end_hour); delete record.end_hour; }\n\n if (record.color && record.color.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.color.substring(9));\n record.taskId = parsed.taskId;\n record.taskPath = parsed.taskId;\n record.taskName = parsed.taskName;\n record.color = parsed.color;\n if (parsed.date !== undefined) record.date = parsed.date;\n if (parsed.startTime !== undefined) record.startTime = parsed.startTime;\n if (parsed.finishTime !== undefined) record.finishTime = parsed.finishTime;\n if (parsed.hours !== undefined) record.hours = parsed.hours;\n if (parsed.startHour !== undefined && parsed.startHour !== null) record.startHour = parsed.startHour;\n if (parsed.endHour !== undefined && parsed.endHour !== null) record.endHour = parsed.endHour;\n if (parsed.dayOffset !== undefined && parsed.dayOffset !== null) record.dayOffset = parsed.dayOffset;\n } catch (e) {\n console.error(e);\n }\n }\n if (collection === 'invoices') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.invoiceType = 'Standard';\n record.laborProfileId = '';\n record.issueDate = '';\n record.originalQuoteId = '';\n record.originalQuoteNumber = '';\n record.originalSubtotal = 0;\n record.approvedVariationsSum = 0;\n record.pendingVariationsSum = 0;\n record.totalInternalCost = 0;\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.invoiceType = meta.invoiceType || 'Standard';\n record.laborProfileId = meta.laborProfileId || '';\n record.issueDate = meta.issueDate || '';\n record.originalQuoteId = meta.originalQuoteId || '';\n record.originalQuoteNumber = meta.originalQuoteNumber || '';\n record.originalSubtotal = meta.originalSubtotal || 0;\n record.approvedVariationsSum = meta.approvedVariationsSum || 0;\n record.pendingVariationsSum = meta.pendingVariationsSum || 0;\n record.totalInternalCost = meta.totalInternalCost || 0;\n record.jobIds = meta.jobIds || []; // consolidated invoices link multiple jobs\n }\n }\n if (collection === 'quotes') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.laborProfileId = '';\n record.description = '';\n record.isTemplate = false;\n record.templateId = '';\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.laborProfileId = meta.laborProfileId || '';\n record.description = meta.description || '';\n record.isTemplate = meta.isTemplate || false;\n record.templateId = meta.templateId || '';\n }\n }\n if (collection === 'jobs') {\n if (record.notes && record.notes.startsWith('__meta__:')) {\n try {\n const meta = JSON.parse(record.notes.substring(9));\n record.isRecurring = meta.isRecurring || false;\n record.recurringConfig = meta.recurringConfig || null;\n record.preferredTime = meta.preferredTime || '';\n record.description = meta.description || '';\n record.parentJobId = meta.parentJobId || null;\n record.assetName = meta.assetName || '';\n record.notes = meta.notes || '';\n } catch (e) {\n console.error('Error parsing jobs meta:', e);\n }\n }\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (collection === 'contractors') {\n record.name = record.businessName;\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (collection === 'suppliers') {\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.xeroSalesAccountCode !== undefined) {\n record.xero_sales_account_code = record.xeroSalesAccountCode;\n delete record.xeroSalesAccountCode;\n }\n if (record.xeroExpenseAccountCode !== undefined) {\n record.xero_expense_account_code = record.xeroExpenseAccountCode;\n delete record.xeroExpenseAccountCode;\n }\n if (record.xeroTrackingCategoryName !== undefined) {\n record.xero_tracking_category_name = record.xeroTrackingCategoryName;\n delete record.xeroTrackingCategoryName;\n }\n if (record.xeroTrackingOptionName !== undefined) {\n record.xero_tracking_option_name = record.xeroTrackingOptionName;\n delete record.xeroTrackingOptionName;\n }\n if (record.deactivatedAt !== undefined) {\n record.deactivated_at = record.deactivatedAt;\n delete record.deactivatedAt;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.projectId !== undefined) {\n record.project_id = record.projectId;\n delete record.projectId;\n }\n if (record.costCenterId !== undefined) {\n record.cost_center_id = record.costCenterId;\n delete record.costCenterId;\n }\n if (record.startDate !== undefined) {\n record.start_date = record.startDate;\n delete record.startDate;\n }\n if (record.endDate !== undefined) {\n record.end_date = record.endDate;\n delete record.endDate;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined || record.hours !== undefined) {\n const h = parseFloat(record.durationHours !== undefined ? record.durationHours : record.hours) || 0;\n record.duration_hours = h;\n record.durationHours = h;\n record.hours = h;\n }\n if (record.taskName !== undefined) record.task_name = record.taskName;\n if (record.taskId !== undefined) record.task_id = record.taskId;\n if (record.taskPath !== undefined) record.task_path = record.taskPath;\n if (record.phaseName !== undefined) record.phase_name = record.phaseName;\n if (record.phaseId !== undefined) record.phase_id = record.phaseId;\n if (record.startTime !== undefined) record.start_time = record.startTime;\n if (record.finishTime !== undefined) record.finish_time = record.finishTime;\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.licenseNumber !== undefined) { record.license_number = record.licenseNumber; delete record.licenseNumber; }\n if (record.hourlyRate !== undefined) { record.hourly_rate = record.hourlyRate; delete record.hourlyRate; }\n if (record.afterHoursRate !== undefined) { record.after_hours_rate = record.afterHoursRate; delete record.afterHoursRate; }\n if (record.calloutFee !== undefined) { record.callout_fee = record.calloutFee; delete record.calloutFee; }\n if (record.complianceDocs !== undefined) { record.compliance_docs = record.complianceDocs; delete record.complianceDocs; }\n if (record.accountNumber !== undefined) { record.account_number = record.accountNumber; delete record.accountNumber; }\n if (record.paymentTerms !== undefined) { record.payment_terms = record.paymentTerms; delete record.paymentTerms; }\n if (record.jobNumber !== undefined) { record.job_number = record.jobNumber; delete record.jobNumber; }\n if (record.startLocation !== undefined) { record.start_location = record.startLocation; delete record.startLocation; }\n if (record.dayOffset !== undefined) { record.day_offset = record.dayOffset; delete record.dayOffset; }\n if (record.startHour !== undefined) { record.start_hour = record.startHour; delete record.startHour; }\n if (record.endHour !== undefined) { record.end_hour = record.endHour; delete record.endHour; }\n\n if (collection === 'schedule') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || null,\n color: record.color || null,\n date: record.date || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n hours: record.hours || null,\n startHour: record.startHour !== undefined ? record.startHour : null,\n endHour: record.endHour !== undefined ? record.endHour : null,\n dayOffset: record.dayOffset !== undefined ? record.dayOffset : null\n };\n record.color = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'jobs') {\n const meta = {\n isRecurring: record.isRecurring || false,\n recurringConfig: record.recurringConfig || null,\n preferredTime: record.preferredTime || '',\n description: record.description || '',\n parentJobId: record.parentJobId || null,\n assetName: record.assetName || '',\n notes: record.notes || ''\n };\n record.notes = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'invoices') {\n const meta = {\n sections: record.sections || [],\n invoiceType: record.invoiceType || 'Standard',\n laborProfileId: record.laborProfileId || '',\n issueDate: record.issueDate || '',\n originalQuoteId: record.originalQuoteId || '',\n originalQuoteNumber: record.originalQuoteNumber || '',\n originalSubtotal: record.originalSubtotal || 0,\n approvedVariationsSum: record.approvedVariationsSum || 0,\n pendingVariationsSum: record.pendingVariationsSum || 0,\n totalInternalCost: record.totalInternalCost || 0,\n jobIds: record.jobIds || [] // consolidated invoices link multiple jobs\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n if (collection === 'quotes') {\n const meta = {\n sections: record.sections || [],\n laborProfileId: record.laborProfileId || '',\n description: record.description || '',\n isTemplate: record.isTemplate || false,\n templateId: record.templateId || ''\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n if (collection === 'timesheets') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || record.phaseName || null,\n taskPath: record.taskPath || null,\n phaseId: record.phaseId || null,\n phaseName: record.phaseName || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n description: record.description || null,\n hours: record.hours !== undefined ? record.hours : record.duration_hours,\n approvedBy: record.approvedBy || null,\n jobNumber: record.jobNumber || null,\n jobTitle: record.jobTitle || null\n };\n record.approved_by = '__meta__:' + JSON.stringify(meta);\n delete record.approvedBy;\n }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n const items = this.cache[collection] || [];\n if (collection === 'technicians') {\n const loginMode = typeof localStorage !== 'undefined' ? localStorage.getItem('relay_login_mode') : null;\n if (loginMode === 'local') {\n const currentUserStr = typeof localStorage !== 'undefined' ? localStorage.getItem('currentUser') : null;\n if (currentUserStr) {\n const currentUser = JSON.parse(currentUserStr);\n if (currentUser && currentUser.id) {\n return [{\n id: currentUser.id,\n name: currentUser.name || 'Local Admin',\n role: 'Administrator',\n color: currentUser.color || '#FF5C00',\n userTypeId: currentUser.userTypeId,\n email: currentUser.email || '',\n username: currentUser.username || ''\n }];\n }\n }\n }\n }\n return items;\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Surface a failed background write to the user instead of failing silently.\n // (A silent insert/update failure leaves the optimistic cache entry in place, so the\n // record looks saved until the next reload — the classic \"it didn't save\" symptom.)\n _notifyWriteError(action, collection, error) {\n console.error(`Error trying to ${action} ${collection}:`, error?.message || error, error);\n try {\n import('../components/Notifications.js')\n .then(({ showToast }) => showToast(\n `Couldn't ${action} ${collection.replace(/s$/, '')} — ${error?.message || 'database error'}`,\n 'error'))\n .catch(() => {});\n } catch (e) {}\n }\n\n // All jobs an invoice bills: single jobId and/or consolidated jobIds\n _invoiceJobIds(inv) {\n return [inv?.jobId, ...(inv?.jobIds || [])].filter(Boolean);\n }\n\n // Invoice lifecycle → job status sync. Jobs are only marked \"Invoiced\" when their\n // invoice actually leaves Draft (Sent/Paid/etc.) — NOT when a draft is created, so\n // abandoning/deleting a draft never strands jobs in the wrong status.\n _markJobsInvoiced(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (job && job.status !== 'Invoiced') this.update('jobs', jid, { status: 'Invoiced' });\n });\n }\n\n // When an invoice is deleted, revert any of its jobs still marked \"Invoiced\" —\n // unless another invoice also bills that job.\n _revertJobsOnInvoiceDelete(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (!job || job.status !== 'Invoiced') return;\n const billedElsewhere = (this.cache.invoices || []).some(other =>\n other.id !== inv.id && this._invoiceJobIds(other).includes(jid));\n if (!billedElsewhere) this.update('jobs', jid, { status: 'Completed' });\n });\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = this.getNextNumber('LD-', 'leads');\n }\n if (collection === 'notifications' && !item.number) {\n item.number = this.getNextNumber('NT-', 'notifications');\n }\n if (collection === 'invoices' && !item.number) {\n item.number = this.getNextNumber('INV-', 'invoices');\n }\n if (collection === 'quotes' && !item.number) {\n item.number = this.getNextNumber('Q-', 'quotes');\n }\n if (collection === 'jobs' && !item.number) {\n item.number = this.getNextNumber('J-', 'jobs');\n }\n if (collection === 'purchaseOrders' && !item.number) {\n item.number = this.getNextNumber('PO-', 'purchaseOrders');\n }\n if (collection === 'projects' && !item.number) {\n item.number = this.getNextNumber('PRJ-', 'projects');\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Rare path: an invoice created already past Draft marks its jobs immediately\n if (collection === 'invoices' && item.status && item.status !== 'Draft' && item.status !== 'Void') {\n this._markJobsInvoiced(item);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Local Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (this.folderSyncEnabled) {\n if (collection === 'documents' && item.url && item.url.startsWith('data:')) {\n this.writeDocumentFileToFolder(item.id, item.name, item.url).then(relPath => {\n if (relPath) {\n item.url = relPath;\n // Re-save in IndexedDB and rewrite folder collection with updated path\n this.writeRecordToIndexedDB(collection, item);\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n });\n } else {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n }\n this.writeRecordToIndexedDB(collection, item).catch(err => {\n console.error(`Error saving ${collection} to IndexedDB:`, err);\n });\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n item.email = email;\n\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) {\n // Roll back the optimistic add so the UI matches the database\n this.cache[collection] = (this.cache[collection] || []).filter(x => x.id !== item.id);\n this.emit(collection, this.cache[collection]);\n this._notifyWriteError('save', collection, error);\n }\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n // Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n this.writeRecordToIndexedDB(collection, updated).catch(err => {\n console.error(`Error saving updated ${collection} to IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const isAuthUpdate = updates.username !== undefined ||\n updates.email !== undefined ||\n updates.password !== undefined ||\n updates.name !== undefined ||\n updates.role !== undefined ||\n updates.userTypeId !== undefined ||\n updates.color !== undefined ||\n updates.payRate !== undefined;\n\n if (isAuthUpdate) {\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to update user.');\n }\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n const dbPayload = this.denormalizeRecord(updated, 'technicians');\n const { error: dbError } = await supabase\n .from('profiles')\n .update(dbPayload)\n .eq('id', id);\n\n if (dbError) {\n throw dbError;\n }\n\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 2b. Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Roll back to the previous version so the UI matches the database\n const arr = [...(this.cache[collection] || [])];\n const i = arr.findIndex(x => x.id === id);\n if (i !== -1) { arr[i] = previous; this.cache[collection] = arr; this.emit(collection, arr); }\n this._notifyWriteError('update', collection, error);\n }\n });\n }\n\n return updated;\n }\n\n // Child records that must not outlive their parent. Deleting a parent cascades to\n // these (scheduling/alert artifacts). Financial/labor records (invoices, timesheets)\n // are deliberately KEPT — they're history and carry their own denormalised numbers.\n _cascadeDelete(collection, id) {\n const CASCADES = {\n jobs: [ ['schedule', 'jobId'], ['formInstances', 'jobId'], ['notifications', 'jobId'] ],\n assets: [ ['maintenancePlans', 'assetId'], ['notifications', 'assetId'] ],\n formTemplates: [ ['formInstances', 'templateId'] ],\n };\n (CASCADES[collection] || []).forEach(([childCol, fk]) => {\n (this.cache[childCol] || []).filter(c => c[fk] === id).forEach(c => this.delete(childCol, c.id));\n });\n }\n\n delete(collection, id) {\n // 0. Cascade-remove dependent child records BEFORE the parent leaves the cache\n this._cascadeDelete(collection, id);\n\n // 1. Update memory cache (keep a copy of the removed row for rollback)\n const removed = (this.cache[collection] || []).find(item => item.id === id);\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Deleting an invoice frees any jobs it (alone) had marked \"Invoiced\"\n if (collection === 'invoices' && removed) {\n this._revertJobsOnInvoiceDelete(removed);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n this.deleteRecordFromIndexedDB(collection, id).catch(err => {\n console.error(`Error deleting ${id} from ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Restore the row we optimistically removed\n if (removed) { this.cache[collection] = [...(this.cache[collection] || []), removed]; this.emit(collection, this.cache[collection]); }\n this._notifyWriteError('delete', collection, error);\n }\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n /**\n * Generate the next sequential number for a collection.\n * Scans existing records, finds the highest numeric suffix, and returns prefix + (max+1) zero-padded to 5 digits.\n * e.g. getNextNumber('INV-', 'invoices') → 'INV-00001', 'INV-00002', ...\n */\n getNextNumber(defaultPrefix, collection) {\n const settings = this.getSettings();\n const dt = settings.documentTheme || {};\n \n let prefix = defaultPrefix;\n let startingNum = 1;\n\n if (collection === 'invoices') {\n prefix = dt.invoicePrefix !== undefined ? dt.invoicePrefix : 'INV-';\n startingNum = dt.invoiceStartingNumber !== undefined ? parseInt(dt.invoiceStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n } else if (collection === 'quotes') {\n prefix = dt.quotePrefix !== undefined ? dt.quotePrefix : 'Q-';\n startingNum = dt.quoteStartingNumber !== undefined ? parseInt(dt.quoteStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n }\n\n const items = this.getAll(collection) || [];\n let maxNum = startingNum - 1;\n\n items.forEach(item => {\n if (item.number && typeof item.number === 'string' && item.number.startsWith(prefix)) {\n const numStr = item.number.slice(prefix.length);\n const num = parseInt(numStr, 10);\n if (!isNaN(num) && num > maxNum) {\n maxNum = num;\n }\n }\n });\n\n return prefix + (maxNum + 1).toString().padStart(5, '0');\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: defaultLogoLarge,\n logoSmall: defaultLogoSmall,\n markupPercent: 20,\n taxEnabled: true,\n taxRate: 10,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n jobTypes: ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'],\n supplierCategories: ['Electrical', 'Plumbing', 'HVAC', 'Fire Safety', 'Security', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n },\n ai: {\n enabled: (this.companyId && !this.companyId.startsWith('acct_')) ? !!(typeof import.meta.env !== 'undefined' && (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY)) : false,\n apiKey: (this.companyId && !this.companyId.startsWith('acct_')) ? (typeof import.meta.env !== 'undefined' ? (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY || '') : '') : '',\n endpoint: 'https://api.openai.com/v1/chat/completions',\n model: 'gpt-4o-mini',\n systemPrompt: 'You are Relay, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default logos if user has not uploaded custom ones\n if (!merged.logo || merged.logo.includes('logo-large.png')) merged.logo = defaultLogoLarge;\n if (!merged.logoSmall) merged.logoSmall = defaultLogoSmall;\n if (!merged.jobTypes) merged.jobTypes = [...defaultSettings.jobTypes];\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n merged.ai = { ...defaultSettings.ai, ...this.companySettings.ai };\n return merged;\n }\n\n return defaultSettings;\n }\n\n getTaxRate() {\n const settings = this.getSettings();\n if (settings.taxEnabled === false) return 0;\n const rate = settings.taxRate !== undefined ? Number(settings.taxRate) : 10;\n return rate / 100;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('settings'), JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── v1.3 Maps: per-user dispatch start location ────────────────────────────\n // Writes profiles.start_location directly (the technicians update() path goes\n // through the invite-user edge function, which doesn't carry this field).\n // Pass null to clear the override (falls back to the company office).\n async setStartLocation(startLocation) {\n const currentUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null') : null;\n if (!currentUser?.id) throw new Error('Not signed in');\n\n // Update cache first so getStartLocation() sees it immediately\n const techs = [...(this.cache.technicians || [])];\n const i = techs.findIndex(t => t.id === currentUser.id);\n if (i !== -1) {\n techs[i] = { ...techs[i], startLocation };\n this.cache.technicians = techs;\n this.emit('technicians', techs);\n }\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n // Local mode: persist with the rest of the technician record\n await this.writeRecordToIndexedDB?.('technicians', techs[i]).catch?.(() => {});\n return;\n }\n const { error } = await supabase\n .from('profiles')\n .update({ start_location: startLocation })\n .eq('id', currentUser.id);\n if (error) {\n this._notifyWriteError('update', 'start location', error);\n throw error;\n }\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId && !this.companyId.startsWith('acct_')) return true;\n return localStorage.getItem(this.getStorageKey('seeded')) === 'true';\n }\n\n save(collection, items) {\n const prev = this.cache[collection] || [];\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const p = this.writeAllToIndexedDB(collection, items).catch(err => {\n console.error(`Error saving all items to ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, items);\n }\n return p;\n }\n\n // Cloud mode: persist the whole collection. Upsert every current row, then delete\n // any rows that were removed from the array. (Previously this only touched the\n // in-memory cache, so every save() — assign-tech, role edits, form templates,\n // portal edits, stock receiving, mark-read, etc. — silently failed to persist.)\n const table = TABLE_MAP[collection];\n if (!table) return; // cache-only collections (e.g. 'activity') have no table\n\n const payload = items\n .filter(it => it && it.id)\n .map(it => this.denormalizeRecord({ ...it, companyId: this.companyId }, collection));\n if (payload.length) {\n supabase.from(table).upsert(payload).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n\n const currentIds = new Set(items.map(it => it && it.id));\n const removedIds = prev.filter(p => p && p.id && !currentIds.has(p.id)).map(p => p.id);\n if (removedIds.length) {\n supabase.from(table).delete().in('id', removedIds).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n }\n\n markSeeded() {\n localStorage.setItem(this.getStorageKey('seeded'), 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('formTemplates', templates);\n return;\n }\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n async seedDefaultUserTypes() {\n if (!this.companyId) return;\n\n const userTypes = [\n {\n id: `${this.companyId}_ut_admin`,\n name: 'Admin',\n description: 'Full system access',\n permissions: buildGranularPerms(() => true)\n },\n {\n id: `${this.companyId}_ut_manager`,\n name: 'Manager',\n description: 'Can manage most workflows but limited settings access',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company', 'manage_tax'].includes(key);\n return true;\n })\n },\n {\n id: `${this.companyId}_ut_tech`,\n name: 'Technician',\n description: 'Field staff — limited to their own jobs, schedule and timesheets',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n {\n id: `${this.companyId}_ut_office`,\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders', 'Suppliers'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n\n this.cache.userTypes = userTypes;\n this.emit('userTypes', userTypes);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('userTypes', userTypes);\n return;\n }\n\n const dbPayload = userTypes.map(ut => this.denormalizeRecord({ ...ut, companyId: this.companyId }, 'userTypes'));\n const { error } = await supabase.from('user_types').insert(dbPayload);\n if (error) console.error('Error seeding default user types:', error);\n }\n\n async seedDefaultTechnicians() {\n const companyId = this.companyId;\n if (!companyId) return;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const managerTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_manager` : 'ut_manager';\n const techTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_tech` : 'ut_tech';\n const officeTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_office` : 'ut_office';\n\n const defaultTechs = [\n { id: `${companyId}_tech_1`, name: 'Jake Morrow', role: 'Senior Electrician', color: '#3B82F6', userTypeId: adminTypeId, payRate: 95.00, email: 'jake@apexpowerservices.com.au', phone: '0412 233 445', username: 'jake', password: '123456' },\n { id: `${companyId}_tech_2`, name: 'Ryan Holt', role: 'Service Manager', color: '#10B981', userTypeId: managerTypeId, payRate: 85.00, email: 'ryan@apexpowerservices.com.au', phone: '0423 344 556', username: 'ryan', password: '123456' },\n { id: `${companyId}_tech_3`, name: 'Sandra Okafor', role: 'Electrician', color: '#8B5CF6', userTypeId: techTypeId, payRate: 80.00, email: 'sandra@apexpowerservices.com.au', phone: '0434 455 667', username: 'sandra', password: '123456' },\n { id: `${companyId}_tech_4`, name: 'Dean Caruso', role: 'Office Administrator',color: '#F59E0B', userTypeId: officeTypeId, payRate: 50.00, email: 'dean@apexpowerservices.com.au', phone: '0445 566 778', username: 'dean', password: '123456' }\n ];\n\n this.cache.technicians = defaultTechs;\n this.emit('technicians', defaultTechs);\n\n if (companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('technicians', defaultTechs);\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder('technicians', defaultTechs).catch(err => {\n console.error('Error writing seeded technicians to local folder:', err);\n });\n }\n return;\n }\n\n const dbPayload = defaultTechs.map(t => this.denormalizeRecord({ ...t, companyId }, 'technicians'));\n const { error } = await supabase.from('technicians').insert(dbPayload);\n if (error) console.error('Error seeding default technicians:', error);\n }\n\n async migrateLocalToCloud(companyId, adminUserId) {\n this.companyId = companyId;\n this.userId = adminUserId;\n\n // 1. Update the remote company settings with local companySettings\n const currentSettings = this.getSettings();\n const { error: compErr } = await supabase.from('companies').update({\n abn: currentSettings.abn || '',\n phone: currentSettings.phone || '',\n domain: currentSettings.domain || '',\n email: currentSettings.email || '',\n address: currentSettings.address || '',\n settings: currentSettings\n }).eq('id', companyId);\n if (compErr) console.error('Error updating cloud company settings during migration:', compErr);\n\n // 2. Loop through all collections and push to Supabase\n // Skip 'companies' and 'technicians'\n const collectionsToMigrate = Object.keys(TABLE_MAP).filter(col => col !== 'companies' && col !== 'technicians');\n\n for (const col of collectionsToMigrate) {\n const items = this.cache[col] || [];\n if (items.length === 0) continue;\n\n const table = TABLE_MAP[col];\n if (!table) continue;\n\n // Assign the new cloud company_id and denormalize\n const payload = items.map(item => {\n const itemCopy = { ...item, companyId };\n return this.denormalizeRecord(itemCopy, col);\n });\n\n // Write to Supabase using upsert\n const { error } = await supabase.from(table).upsert(payload);\n if (error) {\n console.error(`Error migrating collection ${col}:`, error);\n throw new Error(`Failed to migrate ${col} data: ${error.message}`);\n }\n }\n\n // 3. Clear/close local DB connection and sync from Cloud\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n }\n\n deleteLocalAccountData(accountId) {\n return new Promise((resolve) => {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n if (typeof localStorage !== 'undefined') {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem(`relay_${accountId}_${col}`);\n });\n localStorage.removeItem(`relay_${accountId}_folder_sync_enabled`);\n localStorage.removeItem(`relay_${accountId}_seeded`);\n localStorage.removeItem(`relay_${accountId}_settings`);\n }\n if (typeof window !== 'undefined' && window.indexedDB) {\n const req = window.indexedDB.deleteDatabase(`RelayDispatchDB_${accountId}`);\n req.onsuccess = () => resolve();\n req.onerror = () => resolve();\n req.onblocked = () => {\n console.warn('Database deletion blocked, resolving anyway');\n resolve();\n };\n } else {\n resolve();\n }\n });\n }\n\n async clearAll() {\n this.clearSync();\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n \n if (activeAccount) {\n await this.deleteLocalAccountData(activeAccount);\n } else {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n this.dirHandle = null;\n try {\n await this.clearAllIndexedDB();\n } catch (err) {\n console.error('Error clearing IndexedDB:', err);\n }\n }\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "c39e5a6ec1f8ad3a1147b034d8b59b39a39ea770": {"t": 1785754181.162818, "n": 108380, "h": "2977dfcefb66f07f", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\nconst defaultLogoLarge = new URL('../assets/RELAY_Dispatch_Logo.png', import.meta.url).href;\nconst defaultLogoSmall = new URL('../assets/logo-small.png', import.meta.url).href;\n\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n passwordResetRequests: 'password_reset_requests',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents',\n leads: 'leads',\n schedule: 'schedule',\n projects: 'projects',\n costCenters: 'cost_centers'\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"deactivated\",\n \"deactivated_at\",\n \"start_location\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"project_id\",\n \"cost_center_id\",\n \"created_at\",\n \"updated_at\"\n ],\n projects: [\n \"id\",\n \"company_id\",\n \"number\",\n \"name\",\n \"customer_id\",\n \"customer_name\",\n \"site_address\",\n \"status\",\n \"description\",\n \"start_date\",\n \"end_date\",\n \"created_at\",\n \"updated_at\"\n ],\n costCenters: [\n \"id\",\n \"company_id\",\n \"name\",\n \"code\",\n \"active\",\n \"xero_sales_account_code\",\n \"xero_expense_account_code\",\n \"xero_tracking_category_name\",\n \"xero_tracking_option_name\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"license_number\",\n \"hourly_rate\",\n \"after_hours_rate\",\n \"callout_fee\",\n \"specialties\",\n \"notes\",\n \"portal_token\",\n \"compliance_docs\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"address\",\n \"category\",\n \"account_number\",\n \"payment_terms\",\n \"notes\",\n \"attachments\",\n \"created_at\",\n \"updated_at\"\n ],\n leads: [\n \"id\",\n \"company_id\",\n \"number\",\n \"title\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"status\",\n \"source\",\n \"value\",\n \"description\",\n \"priority\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"created_at\",\n \"updated_at\"\n ],\n password_reset_requests: [\n \"id\",\n \"technician_id\",\n \"employee_id\",\n \"requested_at\",\n \"status\",\n \"token\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"type\",\n \"description\",\n \"priority\",\n \"read\",\n \"number\",\n \"asset_id\",\n \"job_id\",\n \"due_date\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g><text x=\"90\" y=\"36\" fill=\"#2D3134\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"25\" font-weight=\"900\" letter-spacing=\"0.04em\">DISPATCH</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nconst MODULE_PERMS = {\n 'Dashboard': [{ key: 'view' }],\n 'Customers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_contacts' }],\n 'Leads': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'convert' }],\n 'Quotes': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'approve' }, { key: 'convert' }, { key: 'generate_pdf' }],\n 'Jobs': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_tasks' }, { key: 'book_time' }, { key: 'view_costs' }, { key: 'view_quotes_tab' }, { key: 'view_pos_tab' }, { key: 'view_timesheets_tab' }, { key: 'view_invoices_tab' }, { key: 'manage_materials' }, { key: 'create_invoice' }],\n 'Timesheets': [{ key: 'view_own' }, { key: 'view' }, { key: 'create' }, { key: 'approve' }, { key: 'edit_all' }, { key: 'export' }],\n 'Assets': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Schedule': [{ key: 'view_own' }, { key: 'view' }, { key: 'edit' }],\n 'Contractors': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }],\n 'Suppliers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Stock': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Purchase Orders': [{ key: 'view' }, { key: 'create' }, { key: 'approve' }],\n 'Invoices': [{ key: 'view' }, { key: 'create' }, { key: 'send' }, { key: 'void' }],\n 'Reports': [{ key: 'view' }, { key: 'export' }],\n 'Documents': [{ key: 'view' }, { key: 'upload' }],\n 'Settings': [{ key: 'view' }, { key: 'edit_company' }, { key: 'manage_users' }, { key: 'manage_tax' }]\n};\n\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n this.db = null;\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n this.backupDirHandle = null;\n this.backupDirPermissionGranted = false;\n\n // Check if we have a cloud user at boot (before filling cache)\n const bootUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n const isCloudUser = !!(bootUser && bootUser.companyId && !bootUser.companyId.startsWith('acct_'));\n\n if (!isCloudUser && bootUser && bootUser.companyId && typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', bootUser.companyId);\n }\n\n // Pre-initialize empty arrays for collections.\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Load local settings if they exist (only for offline/demo mode)\n if (!isCloudUser && typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId && !this.companyId.startsWith('acct_')) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n // Only clear sync if we were previously in cloud sync mode or companyId is not defined\n if (!this.companyId || !this.companyId.startsWith('acct_')) {\n this.clearSync();\n }\n }\n });\n\n // Auto-trigger sync or local load if user already logged in at boot\n if (isCloudUser) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n this.initPromise = this.initializeCloudSync();\n } else {\n if (bootUser && bootUser.companyId) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n }\n this.initPromise = this.initializeLocalStore();\n }\n }\n\n getStorageKey(collection) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (activeAccount) {\n return `relay_${activeAccount}_${collection}`;\n }\n return `simpro_${collection}`;\n }\n\n getDBName() {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n return activeAccount ? `RelayDispatchDB_${activeAccount}` : 'RelayDispatchDB';\n }\n\n async initializeUser(user) {\n if (user && user.companyId && !user.companyId.startsWith('acct_')) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n } else {\n this.clearSync();\n if (user && user.companyId) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', user.companyId);\n }\n } else {\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n }\n this.initPromise = this.initializeLocalStore();\n await this.initPromise;\n }\n }\n\n async initializeLocalStore() {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n\n try {\n await this.initIndexedDB();\n } catch (e) {\n console.error('Failed to initialize IndexedDB, falling back to empty arrays:', e);\n }\n\n // Load local settings if they exist (namespaced)\n this.companySettings = null;\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n this.emit('settings', this.getSettings());\n\n // Seed default user types and forms if empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n if (this.cache.technicians.length === 0) {\n await this.seedDefaultTechnicians();\n }\n }\n\n initIndexedDB() {\n return new Promise((resolve, reject) => {\n if (typeof window === 'undefined' || !window.indexedDB) {\n return resolve();\n }\n\n const dbName = this.getDBName();\n const request = window.indexedDB.open(dbName, 3);\n\n request.onerror = (e) => {\n console.error('IndexedDB open error:', e.target.error);\n reject(e.target.error);\n };\n\n request.onsuccess = async (e) => {\n this.db = e.target.result;\n \n // 1. Load custom directory sync config from IndexedDB config store if it exists\n try {\n if (this.db.objectStoreNames.contains('config')) {\n await new Promise((resConfig) => {\n const transaction = this.db.transaction('config', 'readonly');\n const configStore = transaction.objectStore('config');\n \n const getSync = new Promise((resolve) => {\n const req = configStore.get('directory_handle');\n req.onsuccess = () => {\n console.log('[DEBUG Store] getSync onsuccess result:', req.result);\n if (req.result) {\n this.dirHandle = req.result.value;\n console.log('[DEBUG Store] loaded dirHandle:', this.dirHandle ? this.dirHandle.name : null);\n }\n resolve();\n };\n req.onerror = (e) => {\n console.error('[DEBUG Store] getSync onerror:', e);\n resolve();\n };\n });\n\n const getBackup = new Promise((resolve) => {\n const req = configStore.get('backup_directory_handle');\n req.onsuccess = () => {\n if (req.result) this.backupDirHandle = req.result.value;\n resolve();\n };\n req.onerror = () => resolve();\n });\n\n Promise.all([getSync, getBackup]).then(() => resConfig());\n });\n }\n } catch (err) {\n console.warn('Failed to read config from IndexedDB:', err);\n }\n\n // Verify directory permission silently on boot if handle exists\n if (this.dirHandle) {\n try {\n // Check permission silently without prompt dialog\n const opts = { mode: 'readwrite' };\n const status = await this.dirHandle.queryPermission(opts);\n this.folderSyncPermissionGranted = status === 'granted';\n } catch (err) {\n console.warn('Failed to query directory permission on boot:', err);\n }\n }\n\n // Verify backup directory permission silently on boot if handle exists\n if (this.backupDirHandle) {\n try {\n const opts = { mode: 'readwrite' };\n const status = await this.backupDirHandle.queryPermission(opts);\n this.backupDirPermissionGranted = status === 'granted';\n\n // Auto-trigger backup if granted and > 7 days since last backup\n if (this.backupDirPermissionGranted) {\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;\n if (!lastBackup || new Date(lastBackup).getTime() < sevenDaysAgo) {\n this.backupToFolder().catch(err => {\n console.error('Auto background backup failed:', err);\n });\n }\n }\n } catch (err) {\n console.warn('Failed to query backup directory permission on boot:', err);\n }\n }\n\n // Load all collections into cache\n const collections = Object.keys(TABLE_MAP);\n const loadPromises = collections.map(async (col) => {\n try {\n this.cache[col] = await this.readAllFromIndexedDB(col);\n } catch (err) {\n console.error(`Error loading collection ${col} from IndexedDB:`, err);\n this.cache[col] = [];\n }\n });\n\n await Promise.all(loadPromises);\n\n // Perform migration if we have old data in localStorage (only if not in a namespaced local account profile)\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (!activeAccount) {\n const migratePromises = collections.map(async (col) => {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n const items = JSON.parse(localData);\n if (Array.isArray(items) && items.length > 0) {\n // Write all items to IndexedDB\n await this.writeAllToIndexedDB(col, items);\n // Merge into cache if cache is empty\n if (this.cache[col].length === 0) {\n this.cache[col] = items;\n }\n }\n localStorage.removeItem('simpro_' + col);\n } catch (err) {\n console.error(`Migration error for ${col}:`, err);\n }\n }\n });\n\n await Promise.all(migratePromises);\n }\n resolve();\n };\n\n request.onupgradeneeded = (e) => {\n const db = e.target.result;\n const collections = Object.keys(TABLE_MAP);\n collections.forEach(col => {\n if (!db.objectStoreNames.contains(col)) {\n db.createObjectStore(col, { keyPath: 'id' });\n }\n });\n if (!db.objectStoreNames.contains('config')) {\n db.createObjectStore('config', { keyPath: 'key' });\n }\n };\n });\n }\n\n readAllFromIndexedDB(storeName) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve([]);\n try {\n const transaction = this.db.transaction(storeName, 'readonly');\n const store = transaction.objectStore(storeName);\n const getAllRequest = store.getAll();\n\n getAllRequest.onsuccess = () => {\n resolve(getAllRequest.result || []);\n };\n\n getAllRequest.onerror = (e) => {\n reject(e.target.error);\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeRecordToIndexedDB(storeName, record) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const putRequest = store.put(record);\n\n putRequest.onsuccess = () => resolve();\n putRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n deleteRecordFromIndexedDB(storeName, id) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const deleteRequest = store.delete(id);\n\n deleteRequest.onsuccess = () => resolve();\n deleteRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeAllToIndexedDB(storeName, items) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n \n // Clear existing\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n \n clearRequest.onsuccess = () => {\n let count = 0;\n if (items.length === 0) return resolve();\n \n items.forEach(item => {\n const putRequest = store.put(item);\n putRequest.onerror = (e) => reject(e.target.error);\n putRequest.onsuccess = () => {\n count++;\n if (count === items.length) {\n resolve();\n }\n };\n });\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n clearAllIndexedDB() {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const collections = Object.keys(TABLE_MAP);\n const transaction = this.db.transaction(collections, 'readwrite');\n let count = 0;\n \n collections.forEach(col => {\n const store = transaction.objectStore(col);\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n clearRequest.onsuccess = () => {\n count++;\n if (count === collections.length) {\n resolve();\n }\n };\n });\n } catch (err) {\n reject(err);\n }\n });\n }\n\n // ── Local Folder Sync / File System Access Driver ──────────────────────────\n\n async verifyDirPermission(readWrite) {\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n this.folderSyncPermissionGranted = true;\n return true; // Capacitor native has permissions checked at app level\n }\n\n if (!this.dirHandle) return false;\n const opts = {};\n if (readWrite) opts.mode = 'readwrite';\n \n try {\n const currentPerm = await this.dirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.folderSyncPermissionGranted = true;\n return true;\n }\n \n // Request permission (must be called within a user gesture handler)\n const newPerm = await this.dirHandle.requestPermission(opts);\n this.folderSyncPermissionGranted = newPerm === 'granted';\n return this.folderSyncPermissionGranted;\n } catch (e) {\n this.folderSyncPermissionGranted = false;\n return false;\n }\n }\n\n async verifyBackupDirPermission(requestInteractive) {\n if (!this.backupDirHandle) return false;\n const opts = { mode: 'readwrite' };\n try {\n const currentPerm = await this.backupDirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.backupDirPermissionGranted = true;\n return true;\n }\n if (requestInteractive) {\n const newPerm = await this.backupDirHandle.requestPermission(opts);\n this.backupDirPermissionGranted = newPerm === 'granted';\n return this.backupDirPermissionGranted;\n }\n this.backupDirPermissionGranted = false;\n return false;\n } catch (e) {\n this.backupDirPermissionGranted = false;\n return false;\n }\n }\n\n // Ensure a subfolder for the current company exists within the selected directory.\n async ensureCompanyFolderHandle(customDirHandle) {\n const baseHandle = customDirHandle || this.dirHandle;\n if (!baseHandle) return null;\n const rawName = this.companySettings?.name || this.companySettings?.id || 'Company';\n const safeName = rawName.replace(/[\\\\/:*?\"<>|]/g, '_');\n try {\n const companyHandle = await baseHandle.getDirectoryHandle(safeName, { create: true });\n return companyHandle;\n } catch (e) {\n console.error('Failed to get/create company folder:', e);\n return null;\n }\n }\n\n async setBackupDirectory(dirHandle) {\n this.backupDirHandle = dirHandle || null;\n this.backupDirPermissionGranted = !!dirHandle;\n \n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n if (dirHandle) {\n const putReq = configStore.put({ key: 'backup_directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = () => resolve();\n } else {\n const deleteReq = configStore.delete('backup_directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n }\n });\n }\n }\n\n async backupToFolder(dirHandle) {\n const handle = dirHandle || this.backupDirHandle;\n if (!handle) throw new Error('No backup directory configured.');\n \n const currentPerm = await handle.queryPermission({ mode: 'readwrite' });\n if (currentPerm !== 'granted') {\n const newPerm = await handle.requestPermission({ mode: 'readwrite' });\n if (newPerm !== 'granted') {\n throw new Error('Write permission denied for backup directory.');\n }\n }\n \n // Ensure company-specific folder exists within the selected directory\n const companyFolderHandle = await this.ensureCompanyFolderHandle(dirHandle);\n const dataDir = await companyFolderHandle.getDirectoryHandle('data', { create: true });\n const collections = Object.keys(TABLE_MAP);\n for (const col of collections) {\n const items = this.cache[col] || [];\n const fileHandle = await dataDir.getFileHandle(`${col}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items, null, 2));\n await writable.close();\n }\n \n // Update last backup timestamp\n localStorage.setItem('relay_last_backup_time', new Date().toISOString());\n this.backupDirPermissionGranted = true;\n }\n\n async setLocalDirectory(dirHandle) {\n this.dirHandle = dirHandle;\n if (dirHandle) {\n this.folderSyncEnabled = true;\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('folder_sync_enabled'), 'true');\n }\n \n // Save handle to IndexedDB\n if (this.db) {\n await new Promise((resolve, reject) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const putReq = configStore.put({ key: 'directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = (e) => reject(e.target.error);\n });\n }\n \n this.folderSyncPermissionGranted = true;\n \n // Force rewrite all cached collections to directory immediately\n const collections = Object.keys(TABLE_MAP);\n await Promise.all(collections.map(col => this.writeCollectionToFolder(col, this.cache[col])));\n } else {\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n \n // Remove handle from IndexedDB\n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const deleteReq = configStore.delete('directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n });\n }\n }\n }\n\n async writeCollectionToFolder(collection, items) {\n if (!this.folderSyncEnabled) return;\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/data/${collection}.json`,\n data: JSON.stringify(items || [], null, 2),\n directory: 'DOCUMENTS',\n encoding: 'utf8',\n recursive: true\n });\n } catch (err) {\n console.error(`Capacitor failed to write collection ${collection}:`, err);\n }\n return;\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return;\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return;\n const dataDir = await companyFolder.getDirectoryHandle('data', { create: true });\n const fileHandle = await dataDir.getFileHandle(`${collection}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items || [], null, 2));\n await writable.close();\n } catch (err) {\n console.error(`Failed to write collection ${collection} to local directory:`, err);\n }\n }\n\n async writeDocumentFileToFolder(docId, name, dataUrl) {\n if (!this.folderSyncEnabled) return null;\n\n const ext = name.includes('.') ? name.split('.').pop() : 'bin';\n const fileName = `${docId}.${ext}`;\n const relativePath = `documents/${fileName}`;\n\n // Extract base64 payload\n const base64Parts = dataUrl.split(',');\n if (base64Parts.length < 2) return null;\n const base64Data = base64Parts[1];\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/documents/${fileName}`,\n data: base64Data,\n directory: 'DOCUMENTS',\n recursive: true\n });\n return relativePath;\n } catch (err) {\n console.error('Capacitor failed to write document:', err);\n return null;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return null;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return null;\n\n const mimeMatch = base64Parts[0].match(/:(.*?);/);\n const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';\n const binaryStr = atob(base64Data);\n const len = binaryStr.length;\n const bytes = new Uint8Array(len);\n for (let i = 0; i < len; i++) {\n bytes[i] = binaryStr.charCodeAt(i);\n }\n const blob = new Blob([bytes], { type: mime });\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return null;\n const docsDir = await companyFolder.getDirectoryHandle('documents', { create: true });\n const fileHandle = await docsDir.getFileHandle(fileName, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(blob);\n await writable.close();\n\n return relativePath;\n } catch (err) {\n console.error('Failed to save document file to local directory:', err);\n return null;\n }\n }\n\n async getDocumentUrl(doc) {\n if (!doc || !doc.url) return '';\n if (doc.url.startsWith('data:') || doc.url.startsWith('http')) {\n return doc.url;\n }\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n const result = await Filesystem.readFile({\n path: `RelayDispatchData/${doc.url}`,\n directory: 'DOCUMENTS'\n });\n const mime = doc.type || 'application/octet-stream';\n return `data:${mime};base64,${result.data}`;\n } catch (err) {\n console.error('Capacitor failed to read document file:', err);\n throw err;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) {\n throw new Error('Local directory sync is not configured.');\n }\n\n try {\n const hasPerm = await this.verifyDirPermission(false);\n if (!hasPerm) {\n throw new Error('Permission to access the local directory was not granted.');\n }\n\n const parts = doc.url.split('/');\n const dirName = parts[0];\n const fileName = parts[1];\n\n const dir = await this.dirHandle.getDirectoryHandle(dirName);\n const fileHandle = await dir.getFileHandle(fileName);\n const file = await fileHandle.getFile();\n return URL.createObjectURL(file);\n } catch (err) {\n console.error('Failed to read file from local directory:', err);\n throw err;\n }\n }\n\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // Initialize IndexedDB for cloud users too, to store backup directory handles\n await this.initIndexedDB();\n\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id\n query = query.eq('company_id', this.companyId);\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n await this.writeAllToIndexedDB(col, this.cache[col]);\n }\n });\n\n await Promise.all(promises);\n\n // Seed default user types if database is empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n // Unique channel name per subscribe — reusing a fixed name throws\n // \"cannot add postgres_changes callbacks ... after subscribe()\" when sync re-inits.\n const channel = supabase\n .channel(`table-db-changes-${this.companyId || 'anon'}-${Date.now()}`)\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n if (collection === 'contractors') {\n record.businessName = record.name;\n record.active = record.status === 'Active';\n }\n\n if (collection === 'suppliers') {\n record.active = record.status === 'Active';\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.project_id !== undefined) {\n record.projectId = record.project_id;\n delete record.project_id;\n }\n if (record.cost_center_id !== undefined) {\n record.costCenterId = record.cost_center_id;\n delete record.cost_center_id;\n }\n if (record.xero_sales_account_code !== undefined) {\n record.xeroSalesAccountCode = record.xero_sales_account_code;\n delete record.xero_sales_account_code;\n }\n if (record.xero_expense_account_code !== undefined) {\n record.xeroExpenseAccountCode = record.xero_expense_account_code;\n delete record.xero_expense_account_code;\n }\n if (record.xero_tracking_category_name !== undefined) {\n record.xeroTrackingCategoryName = record.xero_tracking_category_name;\n delete record.xero_tracking_category_name;\n }\n if (record.xero_tracking_option_name !== undefined) {\n record.xeroTrackingOptionName = record.xero_tracking_option_name;\n delete record.xero_tracking_option_name;\n }\n if (record.deactivated_at !== undefined) {\n record.deactivatedAt = record.deactivated_at;\n delete record.deactivated_at;\n }\n if (record.start_date !== undefined) {\n record.startDate = record.start_date;\n delete record.start_date;\n }\n if (record.end_date !== undefined) {\n record.endDate = record.end_date;\n delete record.end_date;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.license_number !== undefined) { record.licenseNumber = record.license_number; delete record.license_number; }\n if (record.hourly_rate !== undefined) { record.hourlyRate = parseFloat(record.hourly_rate); delete record.hourly_rate; }\n if (record.after_hours_rate !== undefined) { record.afterHoursRate = parseFloat(record.after_hours_rate); delete record.after_hours_rate; }\n if (record.callout_fee !== undefined) { record.calloutFee = parseFloat(record.callout_fee); delete record.callout_fee; }\n if (record.compliance_docs !== undefined) { record.complianceDocs = record.compliance_docs; delete record.compliance_docs; }\n if (record.account_number !== undefined) { record.accountNumber = record.account_number; delete record.account_number; }\n if (record.payment_terms !== undefined) { record.paymentTerms = record.payment_terms; delete record.payment_terms; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.start_location !== undefined) { record.startLocation = record.start_location; delete record.start_location; }\n if (record.day_offset !== undefined) { record.dayOffset = parseInt(record.day_offset); delete record.day_offset; }\n if (record.start_hour !== undefined) { record.startHour = parseFloat(record.start_hour); delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = parseFloat(record.end_hour); delete record.end_hour; }\n\n if (record.color && record.color.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.color.substring(9));\n record.taskId = parsed.taskId;\n record.taskPath = parsed.taskId;\n record.taskName = parsed.taskName;\n record.color = parsed.color;\n if (parsed.date !== undefined) record.date = parsed.date;\n if (parsed.startTime !== undefined) record.startTime = parsed.startTime;\n if (parsed.finishTime !== undefined) record.finishTime = parsed.finishTime;\n if (parsed.hours !== undefined) record.hours = parsed.hours;\n if (parsed.startHour !== undefined && parsed.startHour !== null) record.startHour = parsed.startHour;\n if (parsed.endHour !== undefined && parsed.endHour !== null) record.endHour = parsed.endHour;\n if (parsed.dayOffset !== undefined && parsed.dayOffset !== null) record.dayOffset = parsed.dayOffset;\n } catch (e) {\n console.error(e);\n }\n }\n if (collection === 'invoices') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.invoiceType = 'Standard';\n record.laborProfileId = '';\n record.issueDate = '';\n record.originalQuoteId = '';\n record.originalQuoteNumber = '';\n record.originalSubtotal = 0;\n record.approvedVariationsSum = 0;\n record.pendingVariationsSum = 0;\n record.totalInternalCost = 0;\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.invoiceType = meta.invoiceType || 'Standard';\n record.laborProfileId = meta.laborProfileId || '';\n record.issueDate = meta.issueDate || '';\n record.originalQuoteId = meta.originalQuoteId || '';\n record.originalQuoteNumber = meta.originalQuoteNumber || '';\n record.originalSubtotal = meta.originalSubtotal || 0;\n record.approvedVariationsSum = meta.approvedVariationsSum || 0;\n record.pendingVariationsSum = meta.pendingVariationsSum || 0;\n record.totalInternalCost = meta.totalInternalCost || 0;\n record.jobIds = meta.jobIds || []; // consolidated invoices link multiple jobs\n }\n }\n if (collection === 'quotes') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.laborProfileId = '';\n record.description = '';\n record.isTemplate = false;\n record.templateId = '';\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.laborProfileId = meta.laborProfileId || '';\n record.description = meta.description || '';\n record.isTemplate = meta.isTemplate || false;\n record.templateId = meta.templateId || '';\n }\n }\n if (collection === 'jobs') {\n if (record.notes && record.notes.startsWith('__meta__:')) {\n try {\n const meta = JSON.parse(record.notes.substring(9));\n record.isRecurring = meta.isRecurring || false;\n record.recurringConfig = meta.recurringConfig || null;\n record.preferredTime = meta.preferredTime || '';\n record.description = meta.description || '';\n record.parentJobId = meta.parentJobId || null;\n record.notes = meta.notes || '';\n } catch (e) {\n console.error('Error parsing jobs meta:', e);\n }\n }\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (collection === 'contractors') {\n record.name = record.businessName;\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (collection === 'suppliers') {\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.xeroSalesAccountCode !== undefined) {\n record.xero_sales_account_code = record.xeroSalesAccountCode;\n delete record.xeroSalesAccountCode;\n }\n if (record.xeroExpenseAccountCode !== undefined) {\n record.xero_expense_account_code = record.xeroExpenseAccountCode;\n delete record.xeroExpenseAccountCode;\n }\n if (record.xeroTrackingCategoryName !== undefined) {\n record.xero_tracking_category_name = record.xeroTrackingCategoryName;\n delete record.xeroTrackingCategoryName;\n }\n if (record.xeroTrackingOptionName !== undefined) {\n record.xero_tracking_option_name = record.xeroTrackingOptionName;\n delete record.xeroTrackingOptionName;\n }\n if (record.deactivatedAt !== undefined) {\n record.deactivated_at = record.deactivatedAt;\n delete record.deactivatedAt;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.projectId !== undefined) {\n record.project_id = record.projectId;\n delete record.projectId;\n }\n if (record.costCenterId !== undefined) {\n record.cost_center_id = record.costCenterId;\n delete record.costCenterId;\n }\n if (record.startDate !== undefined) {\n record.start_date = record.startDate;\n delete record.startDate;\n }\n if (record.endDate !== undefined) {\n record.end_date = record.endDate;\n delete record.endDate;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.licenseNumber !== undefined) { record.license_number = record.licenseNumber; delete record.licenseNumber; }\n if (record.hourlyRate !== undefined) { record.hourly_rate = record.hourlyRate; delete record.hourlyRate; }\n if (record.afterHoursRate !== undefined) { record.after_hours_rate = record.afterHoursRate; delete record.afterHoursRate; }\n if (record.calloutFee !== undefined) { record.callout_fee = record.calloutFee; delete record.calloutFee; }\n if (record.complianceDocs !== undefined) { record.compliance_docs = record.complianceDocs; delete record.complianceDocs; }\n if (record.accountNumber !== undefined) { record.account_number = record.accountNumber; delete record.accountNumber; }\n if (record.paymentTerms !== undefined) { record.payment_terms = record.paymentTerms; delete record.paymentTerms; }\n if (record.jobNumber !== undefined) { record.job_number = record.jobNumber; delete record.jobNumber; }\n if (record.startLocation !== undefined) { record.start_location = record.startLocation; delete record.startLocation; }\n if (record.dayOffset !== undefined) { record.day_offset = record.dayOffset; delete record.dayOffset; }\n if (record.startHour !== undefined) { record.start_hour = record.startHour; delete record.startHour; }\n if (record.endHour !== undefined) { record.end_hour = record.endHour; delete record.endHour; }\n\n if (collection === 'schedule') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || null,\n color: record.color || null,\n date: record.date || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n hours: record.hours || null,\n startHour: record.startHour !== undefined ? record.startHour : null,\n endHour: record.endHour !== undefined ? record.endHour : null,\n dayOffset: record.dayOffset !== undefined ? record.dayOffset : null\n };\n record.color = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'jobs') {\n const meta = {\n isRecurring: record.isRecurring || false,\n recurringConfig: record.recurringConfig || null,\n preferredTime: record.preferredTime || '',\n description: record.description || '',\n parentJobId: record.parentJobId || null,\n notes: record.notes || ''\n };\n record.notes = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'invoices') {\n const meta = {\n sections: record.sections || [],\n invoiceType: record.invoiceType || 'Standard',\n laborProfileId: record.laborProfileId || '',\n issueDate: record.issueDate || '',\n originalQuoteId: record.originalQuoteId || '',\n originalQuoteNumber: record.originalQuoteNumber || '',\n originalSubtotal: record.originalSubtotal || 0,\n approvedVariationsSum: record.approvedVariationsSum || 0,\n pendingVariationsSum: record.pendingVariationsSum || 0,\n totalInternalCost: record.totalInternalCost || 0,\n jobIds: record.jobIds || [] // consolidated invoices link multiple jobs\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n if (collection === 'quotes') {\n const meta = {\n sections: record.sections || [],\n laborProfileId: record.laborProfileId || '',\n description: record.description || '',\n isTemplate: record.isTemplate || false,\n templateId: record.templateId || ''\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n const items = this.cache[collection] || [];\n if (collection === 'technicians') {\n const loginMode = typeof localStorage !== 'undefined' ? localStorage.getItem('relay_login_mode') : null;\n if (loginMode === 'local') {\n const currentUserStr = typeof localStorage !== 'undefined' ? localStorage.getItem('currentUser') : null;\n if (currentUserStr) {\n const currentUser = JSON.parse(currentUserStr);\n if (currentUser && currentUser.id) {\n return [{\n id: currentUser.id,\n name: currentUser.name || 'Local Admin',\n role: 'Administrator',\n color: currentUser.color || '#FF5C00',\n userTypeId: currentUser.userTypeId,\n email: currentUser.email || '',\n username: currentUser.username || ''\n }];\n }\n }\n }\n }\n return items;\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Surface a failed background write to the user instead of failing silently.\n // (A silent insert/update failure leaves the optimistic cache entry in place, so the\n // record looks saved until the next reload — the classic \"it didn't save\" symptom.)\n _notifyWriteError(action, collection, error) {\n console.error(`Error trying to ${action} ${collection}:`, error?.message || error, error);\n try {\n import('../components/Notifications.js')\n .then(({ showToast }) => showToast(\n `Couldn't ${action} ${collection.replace(/s$/, '')} — ${error?.message || 'database error'}`,\n 'error'))\n .catch(() => {});\n } catch (e) {}\n }\n\n // All jobs an invoice bills: single jobId and/or consolidated jobIds\n _invoiceJobIds(inv) {\n return [inv?.jobId, ...(inv?.jobIds || [])].filter(Boolean);\n }\n\n // Invoice lifecycle → job status sync. Jobs are only marked \"Invoiced\" when their\n // invoice actually leaves Draft (Sent/Paid/etc.) — NOT when a draft is created, so\n // abandoning/deleting a draft never strands jobs in the wrong status.\n _markJobsInvoiced(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (job && job.status !== 'Invoiced') this.update('jobs', jid, { status: 'Invoiced' });\n });\n }\n\n // When an invoice is deleted, revert any of its jobs still marked \"Invoiced\" —\n // unless another invoice also bills that job.\n _revertJobsOnInvoiceDelete(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (!job || job.status !== 'Invoiced') return;\n const billedElsewhere = (this.cache.invoices || []).some(other =>\n other.id !== inv.id && this._invoiceJobIds(other).includes(jid));\n if (!billedElsewhere) this.update('jobs', jid, { status: 'Completed' });\n });\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = this.getNextNumber('LD-', 'leads');\n }\n if (collection === 'notifications' && !item.number) {\n item.number = this.getNextNumber('NT-', 'notifications');\n }\n if (collection === 'invoices' && !item.number) {\n item.number = this.getNextNumber('INV-', 'invoices');\n }\n if (collection === 'quotes' && !item.number) {\n item.number = this.getNextNumber('Q-', 'quotes');\n }\n if (collection === 'jobs' && !item.number) {\n item.number = this.getNextNumber('J-', 'jobs');\n }\n if (collection === 'purchaseOrders' && !item.number) {\n item.number = this.getNextNumber('PO-', 'purchaseOrders');\n }\n if (collection === 'projects' && !item.number) {\n item.number = this.getNextNumber('PRJ-', 'projects');\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Rare path: an invoice created already past Draft marks its jobs immediately\n if (collection === 'invoices' && item.status && item.status !== 'Draft' && item.status !== 'Void') {\n this._markJobsInvoiced(item);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Local Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (this.folderSyncEnabled) {\n if (collection === 'documents' && item.url && item.url.startsWith('data:')) {\n this.writeDocumentFileToFolder(item.id, item.name, item.url).then(relPath => {\n if (relPath) {\n item.url = relPath;\n // Re-save in IndexedDB and rewrite folder collection with updated path\n this.writeRecordToIndexedDB(collection, item);\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n });\n } else {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n }\n this.writeRecordToIndexedDB(collection, item).catch(err => {\n console.error(`Error saving ${collection} to IndexedDB:`, err);\n });\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n item.email = email;\n\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) {\n // Roll back the optimistic add so the UI matches the database\n this.cache[collection] = (this.cache[collection] || []).filter(x => x.id !== item.id);\n this.emit(collection, this.cache[collection]);\n this._notifyWriteError('save', collection, error);\n }\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n // Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n this.writeRecordToIndexedDB(collection, updated).catch(err => {\n console.error(`Error saving updated ${collection} to IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const isAuthUpdate = updates.username !== undefined ||\n updates.email !== undefined ||\n updates.password !== undefined ||\n updates.name !== undefined ||\n updates.role !== undefined ||\n updates.userTypeId !== undefined ||\n updates.color !== undefined ||\n updates.payRate !== undefined;\n\n if (isAuthUpdate) {\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to update user.');\n }\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n const dbPayload = this.denormalizeRecord(updated, 'technicians');\n const { error: dbError } = await supabase\n .from('profiles')\n .update(dbPayload)\n .eq('id', id);\n\n if (dbError) {\n throw dbError;\n }\n\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 2b. Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Roll back to the previous version so the UI matches the database\n const arr = [...(this.cache[collection] || [])];\n const i = arr.findIndex(x => x.id === id);\n if (i !== -1) { arr[i] = previous; this.cache[collection] = arr; this.emit(collection, arr); }\n this._notifyWriteError('update', collection, error);\n }\n });\n }\n\n return updated;\n }\n\n // Child records that must not outlive their parent. Deleting a parent cascades to\n // these (scheduling/alert artifacts). Financial/labor records (invoices, timesheets)\n // are deliberately KEPT — they're history and carry their own denormalised numbers.\n _cascadeDelete(collection, id) {\n const CASCADES = {\n jobs: [ ['schedule', 'jobId'], ['formInstances', 'jobId'], ['notifications', 'jobId'] ],\n assets: [ ['maintenancePlans', 'assetId'], ['notifications', 'assetId'] ],\n formTemplates: [ ['formInstances', 'templateId'] ],\n };\n (CASCADES[collection] || []).forEach(([childCol, fk]) => {\n (this.cache[childCol] || []).filter(c => c[fk] === id).forEach(c => this.delete(childCol, c.id));\n });\n }\n\n delete(collection, id) {\n // 0. Cascade-remove dependent child records BEFORE the parent leaves the cache\n this._cascadeDelete(collection, id);\n\n // 1. Update memory cache (keep a copy of the removed row for rollback)\n const removed = (this.cache[collection] || []).find(item => item.id === id);\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Deleting an invoice frees any jobs it (alone) had marked \"Invoiced\"\n if (collection === 'invoices' && removed) {\n this._revertJobsOnInvoiceDelete(removed);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n this.deleteRecordFromIndexedDB(collection, id).catch(err => {\n console.error(`Error deleting ${id} from ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Restore the row we optimistically removed\n if (removed) { this.cache[collection] = [...(this.cache[collection] || []), removed]; this.emit(collection, this.cache[collection]); }\n this._notifyWriteError('delete', collection, error);\n }\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n /**\n * Generate the next sequential number for a collection.\n * Scans existing records, finds the highest numeric suffix, and returns prefix + (max+1) zero-padded to 5 digits.\n * e.g. getNextNumber('INV-', 'invoices') → 'INV-00001', 'INV-00002', ...\n */\n getNextNumber(defaultPrefix, collection) {\n const settings = this.getSettings();\n const dt = settings.documentTheme || {};\n \n let prefix = defaultPrefix;\n let startingNum = 1;\n\n if (collection === 'invoices') {\n prefix = dt.invoicePrefix !== undefined ? dt.invoicePrefix : 'INV-';\n startingNum = dt.invoiceStartingNumber !== undefined ? parseInt(dt.invoiceStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n } else if (collection === 'quotes') {\n prefix = dt.quotePrefix !== undefined ? dt.quotePrefix : 'Q-';\n startingNum = dt.quoteStartingNumber !== undefined ? parseInt(dt.quoteStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n }\n\n const items = this.getAll(collection) || [];\n let maxNum = startingNum - 1;\n\n items.forEach(item => {\n if (item.number && typeof item.number === 'string' && item.number.startsWith(prefix)) {\n const numStr = item.number.slice(prefix.length);\n const num = parseInt(numStr, 10);\n if (!isNaN(num) && num > maxNum) {\n maxNum = num;\n }\n }\n });\n\n return prefix + (maxNum + 1).toString().padStart(5, '0');\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: defaultLogoLarge,\n logoSmall: defaultLogoSmall,\n markupPercent: 20,\n taxEnabled: true,\n taxRate: 10,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n jobTypes: ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'],\n supplierCategories: ['Electrical', 'Plumbing', 'HVAC', 'Fire Safety', 'Security', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n },\n ai: {\n enabled: (this.companyId && !this.companyId.startsWith('acct_')) ? !!(typeof import.meta.env !== 'undefined' && (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY)) : false,\n apiKey: (this.companyId && !this.companyId.startsWith('acct_')) ? (typeof import.meta.env !== 'undefined' ? (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY || '') : '') : '',\n endpoint: 'https://api.openai.com/v1/chat/completions',\n model: 'gpt-4o-mini',\n systemPrompt: 'You are Relay, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default logos if user has not uploaded custom ones\n if (!merged.logo || merged.logo.includes('logo-large.png')) merged.logo = defaultLogoLarge;\n if (!merged.logoSmall) merged.logoSmall = defaultLogoSmall;\n if (!merged.jobTypes) merged.jobTypes = [...defaultSettings.jobTypes];\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n merged.ai = { ...defaultSettings.ai, ...this.companySettings.ai };\n return merged;\n }\n\n return defaultSettings;\n }\n\n getTaxRate() {\n const settings = this.getSettings();\n if (settings.taxEnabled === false) return 0;\n const rate = settings.taxRate !== undefined ? Number(settings.taxRate) : 10;\n return rate / 100;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('settings'), JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── v1.3 Maps: per-user dispatch start location ────────────────────────────\n // Writes profiles.start_location directly (the technicians update() path goes\n // through the invite-user edge function, which doesn't carry this field).\n // Pass null to clear the override (falls back to the company office).\n async setStartLocation(startLocation) {\n const currentUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null') : null;\n if (!currentUser?.id) throw new Error('Not signed in');\n\n // Update cache first so getStartLocation() sees it immediately\n const techs = [...(this.cache.technicians || [])];\n const i = techs.findIndex(t => t.id === currentUser.id);\n if (i !== -1) {\n techs[i] = { ...techs[i], startLocation };\n this.cache.technicians = techs;\n this.emit('technicians', techs);\n }\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n // Local mode: persist with the rest of the technician record\n await this.writeRecordToIndexedDB?.('technicians', techs[i]).catch?.(() => {});\n return;\n }\n const { error } = await supabase\n .from('profiles')\n .update({ start_location: startLocation })\n .eq('id', currentUser.id);\n if (error) {\n this._notifyWriteError('update', 'start location', error);\n throw error;\n }\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId && !this.companyId.startsWith('acct_')) return true;\n return localStorage.getItem(this.getStorageKey('seeded')) === 'true';\n }\n\n save(collection, items) {\n const prev = this.cache[collection] || [];\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const p = this.writeAllToIndexedDB(collection, items).catch(err => {\n console.error(`Error saving all items to ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, items);\n }\n return p;\n }\n\n // Cloud mode: persist the whole collection. Upsert every current row, then delete\n // any rows that were removed from the array. (Previously this only touched the\n // in-memory cache, so every save() — assign-tech, role edits, form templates,\n // portal edits, stock receiving, mark-read, etc. — silently failed to persist.)\n const table = TABLE_MAP[collection];\n if (!table) return; // cache-only collections (e.g. 'activity') have no table\n\n const payload = items\n .filter(it => it && it.id)\n .map(it => this.denormalizeRecord({ ...it, companyId: this.companyId }, collection));\n if (payload.length) {\n supabase.from(table).upsert(payload).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n\n const currentIds = new Set(items.map(it => it && it.id));\n const removedIds = prev.filter(p => p && p.id && !currentIds.has(p.id)).map(p => p.id);\n if (removedIds.length) {\n supabase.from(table).delete().in('id', removedIds).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n }\n\n markSeeded() {\n localStorage.setItem(this.getStorageKey('seeded'), 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('formTemplates', templates);\n return;\n }\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n async seedDefaultUserTypes() {\n if (!this.companyId) return;\n\n const userTypes = [\n {\n id: `${this.companyId}_ut_admin`,\n name: 'Admin',\n description: 'Full system access',\n permissions: buildGranularPerms(() => true)\n },\n {\n id: `${this.companyId}_ut_manager`,\n name: 'Manager',\n description: 'Can manage most workflows but limited settings access',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company', 'manage_tax'].includes(key);\n return true;\n })\n },\n {\n id: `${this.companyId}_ut_tech`,\n name: 'Technician',\n description: 'Field staff — limited to their own jobs, schedule and timesheets',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n {\n id: `${this.companyId}_ut_office`,\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders', 'Suppliers'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n\n this.cache.userTypes = userTypes;\n this.emit('userTypes', userTypes);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('userTypes', userTypes);\n return;\n }\n\n const dbPayload = userTypes.map(ut => this.denormalizeRecord({ ...ut, companyId: this.companyId }, 'userTypes'));\n const { error } = await supabase.from('user_types').insert(dbPayload);\n if (error) console.error('Error seeding default user types:', error);\n }\n\n async seedDefaultTechnicians() {\n const companyId = this.companyId;\n if (!companyId) return;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const managerTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_manager` : 'ut_manager';\n const techTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_tech` : 'ut_tech';\n const officeTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_office` : 'ut_office';\n\n const defaultTechs = [\n { id: `${companyId}_tech_1`, name: 'Jake Morrow', role: 'Senior Electrician', color: '#3B82F6', userTypeId: adminTypeId, payRate: 95.00, email: 'jake@apexpowerservices.com.au', phone: '0412 233 445', username: 'jake', password: '123456' },\n { id: `${companyId}_tech_2`, name: 'Ryan Holt', role: 'Service Manager', color: '#10B981', userTypeId: managerTypeId, payRate: 85.00, email: 'ryan@apexpowerservices.com.au', phone: '0423 344 556', username: 'ryan', password: '123456' },\n { id: `${companyId}_tech_3`, name: 'Sandra Okafor', role: 'Electrician', color: '#8B5CF6', userTypeId: techTypeId, payRate: 80.00, email: 'sandra@apexpowerservices.com.au', phone: '0434 455 667', username: 'sandra', password: '123456' },\n { id: `${companyId}_tech_4`, name: 'Dean Caruso', role: 'Office Administrator',color: '#F59E0B', userTypeId: officeTypeId, payRate: 50.00, email: 'dean@apexpowerservices.com.au', phone: '0445 566 778', username: 'dean', password: '123456' }\n ];\n\n this.cache.technicians = defaultTechs;\n this.emit('technicians', defaultTechs);\n\n if (companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('technicians', defaultTechs);\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder('technicians', defaultTechs).catch(err => {\n console.error('Error writing seeded technicians to local folder:', err);\n });\n }\n return;\n }\n\n const dbPayload = defaultTechs.map(t => this.denormalizeRecord({ ...t, companyId }, 'technicians'));\n const { error } = await supabase.from('technicians').insert(dbPayload);\n if (error) console.error('Error seeding default technicians:', error);\n }\n\n async migrateLocalToCloud(companyId, adminUserId) {\n this.companyId = companyId;\n this.userId = adminUserId;\n\n // 1. Update the remote company settings with local companySettings\n const currentSettings = this.getSettings();\n const { error: compErr } = await supabase.from('companies').update({\n abn: currentSettings.abn || '',\n phone: currentSettings.phone || '',\n domain: currentSettings.domain || '',\n email: currentSettings.email || '',\n address: currentSettings.address || '',\n settings: currentSettings\n }).eq('id', companyId);\n if (compErr) console.error('Error updating cloud company settings during migration:', compErr);\n\n // 2. Loop through all collections and push to Supabase\n // Skip 'companies' and 'technicians'\n const collectionsToMigrate = Object.keys(TABLE_MAP).filter(col => col !== 'companies' && col !== 'technicians');\n\n for (const col of collectionsToMigrate) {\n const items = this.cache[col] || [];\n if (items.length === 0) continue;\n\n const table = TABLE_MAP[col];\n if (!table) continue;\n\n // Assign the new cloud company_id and denormalize\n const payload = items.map(item => {\n const itemCopy = { ...item, companyId };\n return this.denormalizeRecord(itemCopy, col);\n });\n\n // Write to Supabase using upsert\n const { error } = await supabase.from(table).upsert(payload);\n if (error) {\n console.error(`Error migrating collection ${col}:`, error);\n throw new Error(`Failed to migrate ${col} data: ${error.message}`);\n }\n }\n\n // 3. Clear/close local DB connection and sync from Cloud\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n }\n\n deleteLocalAccountData(accountId) {\n return new Promise((resolve) => {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n if (typeof localStorage !== 'undefined') {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem(`relay_${accountId}_${col}`);\n });\n localStorage.removeItem(`relay_${accountId}_folder_sync_enabled`);\n localStorage.removeItem(`relay_${accountId}_seeded`);\n localStorage.removeItem(`relay_${accountId}_settings`);\n }\n if (typeof window !== 'undefined' && window.indexedDB) {\n const req = window.indexedDB.deleteDatabase(`RelayDispatchDB_${accountId}`);\n req.onsuccess = () => resolve();\n req.onerror = () => resolve();\n req.onblocked = () => {\n console.warn('Database deletion blocked, resolving anyway');\n resolve();\n };\n } else {\n resolve();\n }\n });\n }\n\n async clearAll() {\n this.clearSync();\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n \n if (activeAccount) {\n await this.deleteLocalAccountData(activeAccount);\n } else {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n this.dirHandle = null;\n try {\n await this.clearAllIndexedDB();\n } catch (err) {\n console.error('Error clearing IndexedDB:', err);\n }\n }\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "73e0c5484716f41d293e1fa22f157eed2fb43b2b": {"t": 1785754182.7829242, "n": 107000, "h": "18357962407f5375", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\nconst defaultLogoLarge = new URL('../assets/RELAY_Dispatch_Logo.png', import.meta.url).href;\nconst defaultLogoSmall = new URL('../assets/logo-small.png', import.meta.url).href;\n\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n passwordResetRequests: 'password_reset_requests',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents',\n leads: 'leads',\n schedule: 'schedule',\n projects: 'projects',\n costCenters: 'cost_centers'\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"deactivated\",\n \"deactivated_at\",\n \"start_location\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"project_id\",\n \"cost_center_id\",\n \"created_at\",\n \"updated_at\"\n ],\n projects: [\n \"id\",\n \"company_id\",\n \"number\",\n \"name\",\n \"customer_id\",\n \"customer_name\",\n \"site_address\",\n \"status\",\n \"description\",\n \"start_date\",\n \"end_date\",\n \"created_at\",\n \"updated_at\"\n ],\n costCenters: [\n \"id\",\n \"company_id\",\n \"name\",\n \"code\",\n \"active\",\n \"xero_sales_account_code\",\n \"xero_expense_account_code\",\n \"xero_tracking_category_name\",\n \"xero_tracking_option_name\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"license_number\",\n \"hourly_rate\",\n \"after_hours_rate\",\n \"callout_fee\",\n \"specialties\",\n \"notes\",\n \"portal_token\",\n \"compliance_docs\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"address\",\n \"category\",\n \"account_number\",\n \"payment_terms\",\n \"notes\",\n \"attachments\",\n \"created_at\",\n \"updated_at\"\n ],\n leads: [\n \"id\",\n \"company_id\",\n \"number\",\n \"title\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"status\",\n \"source\",\n \"value\",\n \"description\",\n \"priority\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"created_at\",\n \"updated_at\"\n ],\n password_reset_requests: [\n \"id\",\n \"technician_id\",\n \"employee_id\",\n \"requested_at\",\n \"status\",\n \"token\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"type\",\n \"description\",\n \"priority\",\n \"read\",\n \"number\",\n \"asset_id\",\n \"job_id\",\n \"due_date\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g><text x=\"90\" y=\"36\" fill=\"#2D3134\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"25\" font-weight=\"900\" letter-spacing=\"0.04em\">DISPATCH</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nconst MODULE_PERMS = {\n 'Dashboard': [{ key: 'view' }],\n 'Customers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_contacts' }],\n 'Leads': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'convert' }],\n 'Quotes': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'approve' }, { key: 'convert' }, { key: 'generate_pdf' }],\n 'Jobs': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_tasks' }, { key: 'book_time' }, { key: 'view_costs' }, { key: 'view_quotes_tab' }, { key: 'view_pos_tab' }, { key: 'view_timesheets_tab' }, { key: 'view_invoices_tab' }, { key: 'manage_materials' }, { key: 'create_invoice' }],\n 'Timesheets': [{ key: 'view_own' }, { key: 'view' }, { key: 'create' }, { key: 'approve' }, { key: 'edit_all' }, { key: 'export' }],\n 'Assets': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Schedule': [{ key: 'view_own' }, { key: 'view' }, { key: 'edit' }],\n 'Contractors': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }],\n 'Suppliers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Stock': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Purchase Orders': [{ key: 'view' }, { key: 'create' }, { key: 'approve' }],\n 'Invoices': [{ key: 'view' }, { key: 'create' }, { key: 'send' }, { key: 'void' }],\n 'Reports': [{ key: 'view' }, { key: 'export' }],\n 'Documents': [{ key: 'view' }, { key: 'upload' }],\n 'Settings': [{ key: 'view' }, { key: 'edit_company' }, { key: 'manage_users' }, { key: 'manage_tax' }]\n};\n\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n this.db = null;\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n this.backupDirHandle = null;\n this.backupDirPermissionGranted = false;\n\n // Check if we have a cloud user at boot (before filling cache)\n const bootUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n const isCloudUser = !!(bootUser && bootUser.companyId && !bootUser.companyId.startsWith('acct_'));\n\n if (!isCloudUser && bootUser && bootUser.companyId && typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', bootUser.companyId);\n }\n\n // Pre-initialize empty arrays for collections.\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Load local settings if they exist (only for offline/demo mode)\n if (!isCloudUser && typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId && !this.companyId.startsWith('acct_')) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n // Only clear sync if we were previously in cloud sync mode or companyId is not defined\n if (!this.companyId || !this.companyId.startsWith('acct_')) {\n this.clearSync();\n }\n }\n });\n\n // Auto-trigger sync or local load if user already logged in at boot\n if (isCloudUser) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n this.initPromise = this.initializeCloudSync();\n } else {\n if (bootUser && bootUser.companyId) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n }\n this.initPromise = this.initializeLocalStore();\n }\n }\n\n getStorageKey(collection) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (activeAccount) {\n return `relay_${activeAccount}_${collection}`;\n }\n return `simpro_${collection}`;\n }\n\n getDBName() {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n return activeAccount ? `RelayDispatchDB_${activeAccount}` : 'RelayDispatchDB';\n }\n\n async initializeUser(user) {\n if (user && user.companyId && !user.companyId.startsWith('acct_')) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n } else {\n this.clearSync();\n if (user && user.companyId) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', user.companyId);\n }\n } else {\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n }\n this.initPromise = this.initializeLocalStore();\n await this.initPromise;\n }\n }\n\n async initializeLocalStore() {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n\n try {\n await this.initIndexedDB();\n } catch (e) {\n console.error('Failed to initialize IndexedDB, falling back to empty arrays:', e);\n }\n\n // Load local settings if they exist (namespaced)\n this.companySettings = null;\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n this.emit('settings', this.getSettings());\n\n // Seed default user types and forms if empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n if (this.cache.technicians.length === 0) {\n await this.seedDefaultTechnicians();\n }\n }\n\n initIndexedDB() {\n return new Promise((resolve, reject) => {\n if (typeof window === 'undefined' || !window.indexedDB) {\n return resolve();\n }\n\n const dbName = this.getDBName();\n const request = window.indexedDB.open(dbName, 3);\n\n request.onerror = (e) => {\n console.error('IndexedDB open error:', e.target.error);\n reject(e.target.error);\n };\n\n request.onsuccess = async (e) => {\n this.db = e.target.result;\n \n // 1. Load custom directory sync config from IndexedDB config store if it exists\n try {\n if (this.db.objectStoreNames.contains('config')) {\n await new Promise((resConfig) => {\n const transaction = this.db.transaction('config', 'readonly');\n const configStore = transaction.objectStore('config');\n \n const getSync = new Promise((resolve) => {\n const req = configStore.get('directory_handle');\n req.onsuccess = () => {\n console.log('[DEBUG Store] getSync onsuccess result:', req.result);\n if (req.result) {\n this.dirHandle = req.result.value;\n console.log('[DEBUG Store] loaded dirHandle:', this.dirHandle ? this.dirHandle.name : null);\n }\n resolve();\n };\n req.onerror = (e) => {\n console.error('[DEBUG Store] getSync onerror:', e);\n resolve();\n };\n });\n\n const getBackup = new Promise((resolve) => {\n const req = configStore.get('backup_directory_handle');\n req.onsuccess = () => {\n if (req.result) this.backupDirHandle = req.result.value;\n resolve();\n };\n req.onerror = () => resolve();\n });\n\n Promise.all([getSync, getBackup]).then(() => resConfig());\n });\n }\n } catch (err) {\n console.warn('Failed to read config from IndexedDB:', err);\n }\n\n // Verify directory permission silently on boot if handle exists\n if (this.dirHandle) {\n try {\n // Check permission silently without prompt dialog\n const opts = { mode: 'readwrite' };\n const status = await this.dirHandle.queryPermission(opts);\n this.folderSyncPermissionGranted = status === 'granted';\n } catch (err) {\n console.warn('Failed to query directory permission on boot:', err);\n }\n }\n\n // Verify backup directory permission silently on boot if handle exists\n if (this.backupDirHandle) {\n try {\n const opts = { mode: 'readwrite' };\n const status = await this.backupDirHandle.queryPermission(opts);\n this.backupDirPermissionGranted = status === 'granted';\n\n // Auto-trigger backup if granted and > 7 days since last backup\n if (this.backupDirPermissionGranted) {\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;\n if (!lastBackup || new Date(lastBackup).getTime() < sevenDaysAgo) {\n this.backupToFolder().catch(err => {\n console.error('Auto background backup failed:', err);\n });\n }\n }\n } catch (err) {\n console.warn('Failed to query backup directory permission on boot:', err);\n }\n }\n\n // Load all collections into cache\n const collections = Object.keys(TABLE_MAP);\n const loadPromises = collections.map(async (col) => {\n try {\n this.cache[col] = await this.readAllFromIndexedDB(col);\n } catch (err) {\n console.error(`Error loading collection ${col} from IndexedDB:`, err);\n this.cache[col] = [];\n }\n });\n\n await Promise.all(loadPromises);\n\n // Perform migration if we have old data in localStorage (only if not in a namespaced local account profile)\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (!activeAccount) {\n const migratePromises = collections.map(async (col) => {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n const items = JSON.parse(localData);\n if (Array.isArray(items) && items.length > 0) {\n // Write all items to IndexedDB\n await this.writeAllToIndexedDB(col, items);\n // Merge into cache if cache is empty\n if (this.cache[col].length === 0) {\n this.cache[col] = items;\n }\n }\n localStorage.removeItem('simpro_' + col);\n } catch (err) {\n console.error(`Migration error for ${col}:`, err);\n }\n }\n });\n\n await Promise.all(migratePromises);\n }\n resolve();\n };\n\n request.onupgradeneeded = (e) => {\n const db = e.target.result;\n const collections = Object.keys(TABLE_MAP);\n collections.forEach(col => {\n if (!db.objectStoreNames.contains(col)) {\n db.createObjectStore(col, { keyPath: 'id' });\n }\n });\n if (!db.objectStoreNames.contains('config')) {\n db.createObjectStore('config', { keyPath: 'key' });\n }\n };\n });\n }\n\n readAllFromIndexedDB(storeName) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve([]);\n try {\n const transaction = this.db.transaction(storeName, 'readonly');\n const store = transaction.objectStore(storeName);\n const getAllRequest = store.getAll();\n\n getAllRequest.onsuccess = () => {\n resolve(getAllRequest.result || []);\n };\n\n getAllRequest.onerror = (e) => {\n reject(e.target.error);\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeRecordToIndexedDB(storeName, record) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const putRequest = store.put(record);\n\n putRequest.onsuccess = () => resolve();\n putRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n deleteRecordFromIndexedDB(storeName, id) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const deleteRequest = store.delete(id);\n\n deleteRequest.onsuccess = () => resolve();\n deleteRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeAllToIndexedDB(storeName, items) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n \n // Clear existing\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n \n clearRequest.onsuccess = () => {\n let count = 0;\n if (items.length === 0) return resolve();\n \n items.forEach(item => {\n const putRequest = store.put(item);\n putRequest.onerror = (e) => reject(e.target.error);\n putRequest.onsuccess = () => {\n count++;\n if (count === items.length) {\n resolve();\n }\n };\n });\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n clearAllIndexedDB() {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const collections = Object.keys(TABLE_MAP);\n const transaction = this.db.transaction(collections, 'readwrite');\n let count = 0;\n \n collections.forEach(col => {\n const store = transaction.objectStore(col);\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n clearRequest.onsuccess = () => {\n count++;\n if (count === collections.length) {\n resolve();\n }\n };\n });\n } catch (err) {\n reject(err);\n }\n });\n }\n\n // ── Local Folder Sync / File System Access Driver ──────────────────────────\n\n async verifyDirPermission(readWrite) {\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n this.folderSyncPermissionGranted = true;\n return true; // Capacitor native has permissions checked at app level\n }\n\n if (!this.dirHandle) return false;\n const opts = {};\n if (readWrite) opts.mode = 'readwrite';\n \n try {\n const currentPerm = await this.dirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.folderSyncPermissionGranted = true;\n return true;\n }\n \n // Request permission (must be called within a user gesture handler)\n const newPerm = await this.dirHandle.requestPermission(opts);\n this.folderSyncPermissionGranted = newPerm === 'granted';\n return this.folderSyncPermissionGranted;\n } catch (e) {\n this.folderSyncPermissionGranted = false;\n return false;\n }\n }\n\n async verifyBackupDirPermission(requestInteractive) {\n if (!this.backupDirHandle) return false;\n const opts = { mode: 'readwrite' };\n try {\n const currentPerm = await this.backupDirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.backupDirPermissionGranted = true;\n return true;\n }\n if (requestInteractive) {\n const newPerm = await this.backupDirHandle.requestPermission(opts);\n this.backupDirPermissionGranted = newPerm === 'granted';\n return this.backupDirPermissionGranted;\n }\n this.backupDirPermissionGranted = false;\n return false;\n } catch (e) {\n this.backupDirPermissionGranted = false;\n return false;\n }\n }\n\n // Ensure a subfolder for the current company exists within the selected directory.\n async ensureCompanyFolderHandle(customDirHandle) {\n const baseHandle = customDirHandle || this.dirHandle;\n if (!baseHandle) return null;\n const rawName = this.companySettings?.name || this.companySettings?.id || 'Company';\n const safeName = rawName.replace(/[\\\\/:*?\"<>|]/g, '_');\n try {\n const companyHandle = await baseHandle.getDirectoryHandle(safeName, { create: true });\n return companyHandle;\n } catch (e) {\n console.error('Failed to get/create company folder:', e);\n return null;\n }\n }\n\n async setBackupDirectory(dirHandle) {\n this.backupDirHandle = dirHandle || null;\n this.backupDirPermissionGranted = !!dirHandle;\n \n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n if (dirHandle) {\n const putReq = configStore.put({ key: 'backup_directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = () => resolve();\n } else {\n const deleteReq = configStore.delete('backup_directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n }\n });\n }\n }\n\n async backupToFolder(dirHandle) {\n const handle = dirHandle || this.backupDirHandle;\n if (!handle) throw new Error('No backup directory configured.');\n \n const currentPerm = await handle.queryPermission({ mode: 'readwrite' });\n if (currentPerm !== 'granted') {\n const newPerm = await handle.requestPermission({ mode: 'readwrite' });\n if (newPerm !== 'granted') {\n throw new Error('Write permission denied for backup directory.');\n }\n }\n \n // Ensure company-specific folder exists within the selected directory\n const companyFolderHandle = await this.ensureCompanyFolderHandle(dirHandle);\n const dataDir = await companyFolderHandle.getDirectoryHandle('data', { create: true });\n const collections = Object.keys(TABLE_MAP);\n for (const col of collections) {\n const items = this.cache[col] || [];\n const fileHandle = await dataDir.getFileHandle(`${col}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items, null, 2));\n await writable.close();\n }\n \n // Update last backup timestamp\n localStorage.setItem('relay_last_backup_time', new Date().toISOString());\n this.backupDirPermissionGranted = true;\n }\n\n async setLocalDirectory(dirHandle) {\n this.dirHandle = dirHandle;\n if (dirHandle) {\n this.folderSyncEnabled = true;\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('folder_sync_enabled'), 'true');\n }\n \n // Save handle to IndexedDB\n if (this.db) {\n await new Promise((resolve, reject) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const putReq = configStore.put({ key: 'directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = (e) => reject(e.target.error);\n });\n }\n \n this.folderSyncPermissionGranted = true;\n \n // Force rewrite all cached collections to directory immediately\n const collections = Object.keys(TABLE_MAP);\n await Promise.all(collections.map(col => this.writeCollectionToFolder(col, this.cache[col])));\n } else {\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n \n // Remove handle from IndexedDB\n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const deleteReq = configStore.delete('directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n });\n }\n }\n }\n\n async writeCollectionToFolder(collection, items) {\n if (!this.folderSyncEnabled) return;\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/data/${collection}.json`,\n data: JSON.stringify(items || [], null, 2),\n directory: 'DOCUMENTS',\n encoding: 'utf8',\n recursive: true\n });\n } catch (err) {\n console.error(`Capacitor failed to write collection ${collection}:`, err);\n }\n return;\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return;\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return;\n const dataDir = await companyFolder.getDirectoryHandle('data', { create: true });\n const fileHandle = await dataDir.getFileHandle(`${collection}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items || [], null, 2));\n await writable.close();\n } catch (err) {\n console.error(`Failed to write collection ${collection} to local directory:`, err);\n }\n }\n\n async writeDocumentFileToFolder(docId, name, dataUrl) {\n if (!this.folderSyncEnabled) return null;\n\n const ext = name.includes('.') ? name.split('.').pop() : 'bin';\n const fileName = `${docId}.${ext}`;\n const relativePath = `documents/${fileName}`;\n\n // Extract base64 payload\n const base64Parts = dataUrl.split(',');\n if (base64Parts.length < 2) return null;\n const base64Data = base64Parts[1];\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/documents/${fileName}`,\n data: base64Data,\n directory: 'DOCUMENTS',\n recursive: true\n });\n return relativePath;\n } catch (err) {\n console.error('Capacitor failed to write document:', err);\n return null;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return null;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return null;\n\n const mimeMatch = base64Parts[0].match(/:(.*?);/);\n const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';\n const binaryStr = atob(base64Data);\n const len = binaryStr.length;\n const bytes = new Uint8Array(len);\n for (let i = 0; i < len; i++) {\n bytes[i] = binaryStr.charCodeAt(i);\n }\n const blob = new Blob([bytes], { type: mime });\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return null;\n const docsDir = await companyFolder.getDirectoryHandle('documents', { create: true });\n const fileHandle = await docsDir.getFileHandle(fileName, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(blob);\n await writable.close();\n\n return relativePath;\n } catch (err) {\n console.error('Failed to save document file to local directory:', err);\n return null;\n }\n }\n\n async getDocumentUrl(doc) {\n if (!doc || !doc.url) return '';\n if (doc.url.startsWith('data:') || doc.url.startsWith('http')) {\n return doc.url;\n }\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n const result = await Filesystem.readFile({\n path: `RelayDispatchData/${doc.url}`,\n directory: 'DOCUMENTS'\n });\n const mime = doc.type || 'application/octet-stream';\n return `data:${mime};base64,${result.data}`;\n } catch (err) {\n console.error('Capacitor failed to read document file:', err);\n throw err;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) {\n throw new Error('Local directory sync is not configured.');\n }\n\n try {\n const hasPerm = await this.verifyDirPermission(false);\n if (!hasPerm) {\n throw new Error('Permission to access the local directory was not granted.');\n }\n\n const parts = doc.url.split('/');\n const dirName = parts[0];\n const fileName = parts[1];\n\n const dir = await this.dirHandle.getDirectoryHandle(dirName);\n const fileHandle = await dir.getFileHandle(fileName);\n const file = await fileHandle.getFile();\n return URL.createObjectURL(file);\n } catch (err) {\n console.error('Failed to read file from local directory:', err);\n throw err;\n }\n }\n\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // Initialize IndexedDB for cloud users too, to store backup directory handles\n await this.initIndexedDB();\n\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id\n query = query.eq('company_id', this.companyId);\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n await this.writeAllToIndexedDB(col, this.cache[col]);\n }\n });\n\n await Promise.all(promises);\n\n // Seed default user types if database is empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n // Unique channel name per subscribe — reusing a fixed name throws\n // \"cannot add postgres_changes callbacks ... after subscribe()\" when sync re-inits.\n const channel = supabase\n .channel(`table-db-changes-${this.companyId || 'anon'}-${Date.now()}`)\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n if (collection === 'contractors') {\n record.businessName = record.name;\n record.active = record.status === 'Active';\n }\n\n if (collection === 'suppliers') {\n record.active = record.status === 'Active';\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.project_id !== undefined) {\n record.projectId = record.project_id;\n delete record.project_id;\n }\n if (record.cost_center_id !== undefined) {\n record.costCenterId = record.cost_center_id;\n delete record.cost_center_id;\n }\n if (record.xero_sales_account_code !== undefined) {\n record.xeroSalesAccountCode = record.xero_sales_account_code;\n delete record.xero_sales_account_code;\n }\n if (record.xero_expense_account_code !== undefined) {\n record.xeroExpenseAccountCode = record.xero_expense_account_code;\n delete record.xero_expense_account_code;\n }\n if (record.xero_tracking_category_name !== undefined) {\n record.xeroTrackingCategoryName = record.xero_tracking_category_name;\n delete record.xero_tracking_category_name;\n }\n if (record.xero_tracking_option_name !== undefined) {\n record.xeroTrackingOptionName = record.xero_tracking_option_name;\n delete record.xero_tracking_option_name;\n }\n if (record.deactivated_at !== undefined) {\n record.deactivatedAt = record.deactivated_at;\n delete record.deactivated_at;\n }\n if (record.start_date !== undefined) {\n record.startDate = record.start_date;\n delete record.start_date;\n }\n if (record.end_date !== undefined) {\n record.endDate = record.end_date;\n delete record.end_date;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.license_number !== undefined) { record.licenseNumber = record.license_number; delete record.license_number; }\n if (record.hourly_rate !== undefined) { record.hourlyRate = parseFloat(record.hourly_rate); delete record.hourly_rate; }\n if (record.after_hours_rate !== undefined) { record.afterHoursRate = parseFloat(record.after_hours_rate); delete record.after_hours_rate; }\n if (record.callout_fee !== undefined) { record.calloutFee = parseFloat(record.callout_fee); delete record.callout_fee; }\n if (record.compliance_docs !== undefined) { record.complianceDocs = record.compliance_docs; delete record.compliance_docs; }\n if (record.account_number !== undefined) { record.accountNumber = record.account_number; delete record.account_number; }\n if (record.payment_terms !== undefined) { record.paymentTerms = record.payment_terms; delete record.payment_terms; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.start_location !== undefined) { record.startLocation = record.start_location; delete record.start_location; }\n if (record.day_offset !== undefined) { record.dayOffset = parseInt(record.day_offset); delete record.day_offset; }\n if (record.start_hour !== undefined) { record.startHour = parseFloat(record.start_hour); delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = parseFloat(record.end_hour); delete record.end_hour; }\n\n if (record.color && record.color.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.color.substring(9));\n record.taskId = parsed.taskId;\n record.taskPath = parsed.taskId;\n record.taskName = parsed.taskName;\n record.color = parsed.color;\n if (parsed.date !== undefined) record.date = parsed.date;\n if (parsed.startTime !== undefined) record.startTime = parsed.startTime;\n if (parsed.finishTime !== undefined) record.finishTime = parsed.finishTime;\n if (parsed.hours !== undefined) record.hours = parsed.hours;\n if (parsed.startHour !== undefined && parsed.startHour !== null) record.startHour = parsed.startHour;\n if (parsed.endHour !== undefined && parsed.endHour !== null) record.endHour = parsed.endHour;\n if (parsed.dayOffset !== undefined && parsed.dayOffset !== null) record.dayOffset = parsed.dayOffset;\n } catch (e) {\n console.error(e);\n }\n }\n if (collection === 'invoices') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.invoiceType = 'Standard';\n record.laborProfileId = '';\n record.issueDate = '';\n record.originalQuoteId = '';\n record.originalQuoteNumber = '';\n record.originalSubtotal = 0;\n record.approvedVariationsSum = 0;\n record.pendingVariationsSum = 0;\n record.totalInternalCost = 0;\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.invoiceType = meta.invoiceType || 'Standard';\n record.laborProfileId = meta.laborProfileId || '';\n record.issueDate = meta.issueDate || '';\n record.originalQuoteId = meta.originalQuoteId || '';\n record.originalQuoteNumber = meta.originalQuoteNumber || '';\n record.originalSubtotal = meta.originalSubtotal || 0;\n record.approvedVariationsSum = meta.approvedVariationsSum || 0;\n record.pendingVariationsSum = meta.pendingVariationsSum || 0;\n record.totalInternalCost = meta.totalInternalCost || 0;\n record.jobIds = meta.jobIds || []; // consolidated invoices link multiple jobs\n }\n }\n if (collection === 'quotes') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.laborProfileId = '';\n record.description = '';\n record.isTemplate = false;\n record.templateId = '';\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.laborProfileId = meta.laborProfileId || '';\n record.description = meta.description || '';\n record.isTemplate = meta.isTemplate || false;\n record.templateId = meta.templateId || '';\n }\n }\n if (collection === 'jobs') {\n if (record.notes && record.notes.startsWith('__meta__:')) {\n try {\n const meta = JSON.parse(record.notes.substring(9));\n record.isRecurring = meta.isRecurring || false;\n record.recurringConfig = meta.recurringConfig || null;\n record.preferredTime = meta.preferredTime || '';\n record.description = meta.description || '';\n record.parentJobId = meta.parentJobId || null;\n record.notes = meta.notes || '';\n } catch (e) {\n console.error('Error parsing jobs meta:', e);\n }\n }\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (collection === 'contractors') {\n record.name = record.businessName;\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (collection === 'suppliers') {\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.xeroSalesAccountCode !== undefined) {\n record.xero_sales_account_code = record.xeroSalesAccountCode;\n delete record.xeroSalesAccountCode;\n }\n if (record.xeroExpenseAccountCode !== undefined) {\n record.xero_expense_account_code = record.xeroExpenseAccountCode;\n delete record.xeroExpenseAccountCode;\n }\n if (record.xeroTrackingCategoryName !== undefined) {\n record.xero_tracking_category_name = record.xeroTrackingCategoryName;\n delete record.xeroTrackingCategoryName;\n }\n if (record.xeroTrackingOptionName !== undefined) {\n record.xero_tracking_option_name = record.xeroTrackingOptionName;\n delete record.xeroTrackingOptionName;\n }\n if (record.deactivatedAt !== undefined) {\n record.deactivated_at = record.deactivatedAt;\n delete record.deactivatedAt;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.projectId !== undefined) {\n record.project_id = record.projectId;\n delete record.projectId;\n }\n if (record.costCenterId !== undefined) {\n record.cost_center_id = record.costCenterId;\n delete record.costCenterId;\n }\n if (record.startDate !== undefined) {\n record.start_date = record.startDate;\n delete record.startDate;\n }\n if (record.endDate !== undefined) {\n record.end_date = record.endDate;\n delete record.endDate;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.licenseNumber !== undefined) { record.license_number = record.licenseNumber; delete record.licenseNumber; }\n if (record.hourlyRate !== undefined) { record.hourly_rate = record.hourlyRate; delete record.hourlyRate; }\n if (record.afterHoursRate !== undefined) { record.after_hours_rate = record.afterHoursRate; delete record.afterHoursRate; }\n if (record.calloutFee !== undefined) { record.callout_fee = record.calloutFee; delete record.calloutFee; }\n if (record.complianceDocs !== undefined) { record.compliance_docs = record.complianceDocs; delete record.complianceDocs; }\n if (record.accountNumber !== undefined) { record.account_number = record.accountNumber; delete record.accountNumber; }\n if (record.paymentTerms !== undefined) { record.payment_terms = record.paymentTerms; delete record.paymentTerms; }\n if (record.jobNumber !== undefined) { record.job_number = record.jobNumber; delete record.jobNumber; }\n if (record.startLocation !== undefined) { record.start_location = record.startLocation; delete record.startLocation; }\n if (record.dayOffset !== undefined) { record.day_offset = record.dayOffset; delete record.dayOffset; }\n if (record.startHour !== undefined) { record.start_hour = record.startHour; delete record.startHour; }\n if (record.endHour !== undefined) { record.end_hour = record.endHour; delete record.endHour; }\n\n if (collection === 'schedule') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || null,\n color: record.color || null,\n date: record.date || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n hours: record.hours || null,\n startHour: record.startHour !== undefined ? record.startHour : null,\n endHour: record.endHour !== undefined ? record.endHour : null,\n dayOffset: record.dayOffset !== undefined ? record.dayOffset : null\n };\n record.color = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'jobs') {\n const meta = {\n isRecurring: record.isRecurring || false,\n recurringConfig: record.recurringConfig || null,\n preferredTime: record.preferredTime || '',\n description: record.description || '',\n parentJobId: record.parentJobId || null,\n notes: record.notes || ''\n };\n record.notes = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'invoices') {\n const meta = {\n sections: record.sections || [],\n invoiceType: record.invoiceType || 'Standard',\n laborProfileId: record.laborProfileId || '',\n issueDate: record.issueDate || '',\n originalQuoteId: record.originalQuoteId || '',\n originalQuoteNumber: record.originalQuoteNumber || '',\n originalSubtotal: record.originalSubtotal || 0,\n approvedVariationsSum: record.approvedVariationsSum || 0,\n pendingVariationsSum: record.pendingVariationsSum || 0,\n totalInternalCost: record.totalInternalCost || 0,\n jobIds: record.jobIds || [] // consolidated invoices link multiple jobs\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n if (collection === 'quotes') {\n const meta = {\n sections: record.sections || [],\n laborProfileId: record.laborProfileId || '',\n description: record.description || '',\n isTemplate: record.isTemplate || false,\n templateId: record.templateId || ''\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n const items = this.cache[collection] || [];\n if (collection === 'technicians') {\n const loginMode = typeof localStorage !== 'undefined' ? localStorage.getItem('relay_login_mode') : null;\n if (loginMode === 'local') {\n const currentUserStr = typeof localStorage !== 'undefined' ? localStorage.getItem('currentUser') : null;\n if (currentUserStr) {\n const currentUser = JSON.parse(currentUserStr);\n if (currentUser && currentUser.id) {\n return [{\n id: currentUser.id,\n name: currentUser.name || 'Local Admin',\n role: 'Administrator',\n color: currentUser.color || '#FF5C00',\n userTypeId: currentUser.userTypeId,\n email: currentUser.email || '',\n username: currentUser.username || ''\n }];\n }\n }\n }\n }\n return items;\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Surface a failed background write to the user instead of failing silently.\n // (A silent insert/update failure leaves the optimistic cache entry in place, so the\n // record looks saved until the next reload — the classic \"it didn't save\" symptom.)\n _notifyWriteError(action, collection, error) {\n console.error(`Error trying to ${action} ${collection}:`, error?.message || error, error);\n try {\n import('../components/Notifications.js')\n .then(({ showToast }) => showToast(\n `Couldn't ${action} ${collection.replace(/s$/, '')} — ${error?.message || 'database error'}`,\n 'error'))\n .catch(() => {});\n } catch (e) {}\n }\n\n // All jobs an invoice bills: single jobId and/or consolidated jobIds\n _invoiceJobIds(inv) {\n return [inv?.jobId, ...(inv?.jobIds || [])].filter(Boolean);\n }\n\n // Invoice lifecycle → job status sync. Jobs are only marked \"Invoiced\" when their\n // invoice actually leaves Draft (Sent/Paid/etc.) — NOT when a draft is created, so\n // abandoning/deleting a draft never strands jobs in the wrong status.\n _markJobsInvoiced(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (job && job.status !== 'Invoiced') this.update('jobs', jid, { status: 'Invoiced' });\n });\n }\n\n // When an invoice is deleted, revert any of its jobs still marked \"Invoiced\" —\n // unless another invoice also bills that job.\n _revertJobsOnInvoiceDelete(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (!job || job.status !== 'Invoiced') return;\n const billedElsewhere = (this.cache.invoices || []).some(other =>\n other.id !== inv.id && this._invoiceJobIds(other).includes(jid));\n if (!billedElsewhere) this.update('jobs', jid, { status: 'Completed' });\n });\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = this.getNextNumber('LD-', 'leads');\n }\n if (collection === 'notifications' && !item.number) {\n item.number = this.getNextNumber('NT-', 'notifications');\n }\n if (collection === 'invoices' && !item.number) {\n item.number = this.getNextNumber('INV-', 'invoices');\n }\n if (collection === 'quotes' && !item.number) {\n item.number = this.getNextNumber('Q-', 'quotes');\n }\n if (collection === 'jobs' && !item.number) {\n item.number = this.getNextNumber('J-', 'jobs');\n }\n if (collection === 'purchaseOrders' && !item.number) {\n item.number = this.getNextNumber('PO-', 'purchaseOrders');\n }\n if (collection === 'projects' && !item.number) {\n item.number = this.getNextNumber('PRJ-', 'projects');\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Rare path: an invoice created already past Draft marks its jobs immediately\n if (collection === 'invoices' && item.status && item.status !== 'Draft' && item.status !== 'Void') {\n this._markJobsInvoiced(item);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Local Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (this.folderSyncEnabled) {\n if (collection === 'documents' && item.url && item.url.startsWith('data:')) {\n this.writeDocumentFileToFolder(item.id, item.name, item.url).then(relPath => {\n if (relPath) {\n item.url = relPath;\n // Re-save in IndexedDB and rewrite folder collection with updated path\n this.writeRecordToIndexedDB(collection, item);\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n });\n } else {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n }\n this.writeRecordToIndexedDB(collection, item).catch(err => {\n console.error(`Error saving ${collection} to IndexedDB:`, err);\n });\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n item.email = email;\n\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) {\n // Roll back the optimistic add so the UI matches the database\n this.cache[collection] = (this.cache[collection] || []).filter(x => x.id !== item.id);\n this.emit(collection, this.cache[collection]);\n this._notifyWriteError('save', collection, error);\n }\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n // Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n this.writeRecordToIndexedDB(collection, updated).catch(err => {\n console.error(`Error saving updated ${collection} to IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const isAuthUpdate = updates.username !== undefined ||\n updates.email !== undefined ||\n updates.password !== undefined ||\n updates.name !== undefined ||\n updates.role !== undefined ||\n updates.userTypeId !== undefined ||\n updates.color !== undefined ||\n updates.payRate !== undefined;\n\n if (isAuthUpdate) {\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to update user.');\n }\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n const dbPayload = this.denormalizeRecord(updated, 'technicians');\n const { error: dbError } = await supabase\n .from('profiles')\n .update(dbPayload)\n .eq('id', id);\n\n if (dbError) {\n throw dbError;\n }\n\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 2b. Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Roll back to the previous version so the UI matches the database\n const arr = [...(this.cache[collection] || [])];\n const i = arr.findIndex(x => x.id === id);\n if (i !== -1) { arr[i] = previous; this.cache[collection] = arr; this.emit(collection, arr); }\n this._notifyWriteError('update', collection, error);\n }\n });\n }\n\n return updated;\n }\n\n // Child records that must not outlive their parent. Deleting a parent cascades to\n // these (scheduling/alert artifacts). Financial/labor records (invoices, timesheets)\n // are deliberately KEPT — they're history and carry their own denormalised numbers.\n _cascadeDelete(collection, id) {\n const CASCADES = {\n jobs: [ ['schedule', 'jobId'], ['formInstances', 'jobId'], ['notifications', 'jobId'] ],\n assets: [ ['maintenancePlans', 'assetId'], ['notifications', 'assetId'] ],\n formTemplates: [ ['formInstances', 'templateId'] ],\n };\n (CASCADES[collection] || []).forEach(([childCol, fk]) => {\n (this.cache[childCol] || []).filter(c => c[fk] === id).forEach(c => this.delete(childCol, c.id));\n });\n }\n\n delete(collection, id) {\n // 0. Cascade-remove dependent child records BEFORE the parent leaves the cache\n this._cascadeDelete(collection, id);\n\n // 1. Update memory cache (keep a copy of the removed row for rollback)\n const removed = (this.cache[collection] || []).find(item => item.id === id);\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Deleting an invoice frees any jobs it (alone) had marked \"Invoiced\"\n if (collection === 'invoices' && removed) {\n this._revertJobsOnInvoiceDelete(removed);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n this.deleteRecordFromIndexedDB(collection, id).catch(err => {\n console.error(`Error deleting ${id} from ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Restore the row we optimistically removed\n if (removed) { this.cache[collection] = [...(this.cache[collection] || []), removed]; this.emit(collection, this.cache[collection]); }\n this._notifyWriteError('delete', collection, error);\n }\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n /**\n * Generate the next sequential number for a collection.\n * Scans existing records, finds the highest numeric suffix, and returns prefix + (max+1) zero-padded to 5 digits.\n * e.g. getNextNumber('INV-', 'invoices') → 'INV-00001', 'INV-00002', ...\n */\n getNextNumber(defaultPrefix, collection) {\n const settings = this.getSettings();\n const dt = settings.documentTheme || {};\n \n let prefix = defaultPrefix;\n let startingNum = 1;\n\n if (collection === 'invoices') {\n prefix = dt.invoicePrefix !== undefined ? dt.invoicePrefix : 'INV-';\n startingNum = dt.invoiceStartingNumber !== undefined ? parseInt(dt.invoiceStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n } else if (collection === 'quotes') {\n prefix = dt.quotePrefix !== undefined ? dt.quotePrefix : 'Q-';\n startingNum = dt.quoteStartingNumber !== undefined ? parseInt(dt.quoteStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n }\n\n const items = this.getAll(collection) || [];\n let maxNum = startingNum - 1;\n\n items.forEach(item => {\n if (item.number && typeof item.number === 'string' && item.number.startsWith(prefix)) {\n const numStr = item.number.slice(prefix.length);\n const num = parseInt(numStr, 10);\n if (!isNaN(num) && num > maxNum) {\n maxNum = num;\n }\n }\n });\n\n return prefix + (maxNum + 1).toString().padStart(5, '0');\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: defaultLogoLarge,\n logoSmall: defaultLogoSmall,\n markupPercent: 20,\n taxEnabled: true,\n taxRate: 10,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n jobTypes: ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'],\n supplierCategories: ['Electrical', 'Plumbing', 'HVAC', 'Fire Safety', 'Security', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n },\n ai: {\n enabled: (this.companyId && !this.companyId.startsWith('acct_')) ? !!(typeof import.meta.env !== 'undefined' && (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY)) : false,\n apiKey: (this.companyId && !this.companyId.startsWith('acct_')) ? (typeof import.meta.env !== 'undefined' ? (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY || '') : '') : '',\n endpoint: 'https://api.openai.com/v1/chat/completions',\n model: 'gpt-4o-mini',\n systemPrompt: 'You are Relay, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default logos if user has not uploaded custom ones\n if (!merged.logo || merged.logo.includes('logo-large.png')) merged.logo = defaultLogoLarge;\n if (!merged.logoSmall) merged.logoSmall = defaultLogoSmall;\n if (!merged.jobTypes) merged.jobTypes = [...defaultSettings.jobTypes];\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n merged.ai = { ...defaultSettings.ai, ...this.companySettings.ai };\n return merged;\n }\n\n return defaultSettings;\n }\n\n getTaxRate() {\n const settings = this.getSettings();\n if (settings.taxEnabled === false) return 0;\n const rate = settings.taxRate !== undefined ? Number(settings.taxRate) : 10;\n return rate / 100;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('settings'), JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId && !this.companyId.startsWith('acct_')) return true;\n return localStorage.getItem(this.getStorageKey('seeded')) === 'true';\n }\n\n save(collection, items) {\n const prev = this.cache[collection] || [];\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const p = this.writeAllToIndexedDB(collection, items).catch(err => {\n console.error(`Error saving all items to ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, items);\n }\n return p;\n }\n\n // Cloud mode: persist the whole collection. Upsert every current row, then delete\n // any rows that were removed from the array. (Previously this only touched the\n // in-memory cache, so every save() — assign-tech, role edits, form templates,\n // portal edits, stock receiving, mark-read, etc. — silently failed to persist.)\n const table = TABLE_MAP[collection];\n if (!table) return; // cache-only collections (e.g. 'activity') have no table\n\n const payload = items\n .filter(it => it && it.id)\n .map(it => this.denormalizeRecord({ ...it, companyId: this.companyId }, collection));\n if (payload.length) {\n supabase.from(table).upsert(payload).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n\n const currentIds = new Set(items.map(it => it && it.id));\n const removedIds = prev.filter(p => p && p.id && !currentIds.has(p.id)).map(p => p.id);\n if (removedIds.length) {\n supabase.from(table).delete().in('id', removedIds).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n }\n\n markSeeded() {\n localStorage.setItem(this.getStorageKey('seeded'), 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('formTemplates', templates);\n return;\n }\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n async seedDefaultUserTypes() {\n if (!this.companyId) return;\n\n const userTypes = [\n {\n id: `${this.companyId}_ut_admin`,\n name: 'Admin',\n description: 'Full system access',\n permissions: buildGranularPerms(() => true)\n },\n {\n id: `${this.companyId}_ut_manager`,\n name: 'Manager',\n description: 'Can manage most workflows but limited settings access',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company', 'manage_tax'].includes(key);\n return true;\n })\n },\n {\n id: `${this.companyId}_ut_tech`,\n name: 'Technician',\n description: 'Field staff — limited to their own jobs, schedule and timesheets',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n {\n id: `${this.companyId}_ut_office`,\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders', 'Suppliers'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n\n this.cache.userTypes = userTypes;\n this.emit('userTypes', userTypes);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('userTypes', userTypes);\n return;\n }\n\n const dbPayload = userTypes.map(ut => this.denormalizeRecord({ ...ut, companyId: this.companyId }, 'userTypes'));\n const { error } = await supabase.from('user_types').insert(dbPayload);\n if (error) console.error('Error seeding default user types:', error);\n }\n\n async seedDefaultTechnicians() {\n const companyId = this.companyId;\n if (!companyId) return;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const managerTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_manager` : 'ut_manager';\n const techTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_tech` : 'ut_tech';\n const officeTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_office` : 'ut_office';\n\n const defaultTechs = [\n { id: `${companyId}_tech_1`, name: 'Jake Morrow', role: 'Senior Electrician', color: '#3B82F6', userTypeId: adminTypeId, payRate: 95.00, email: 'jake@apexpowerservices.com.au', phone: '0412 233 445', username: 'jake', password: '123456' },\n { id: `${companyId}_tech_2`, name: 'Ryan Holt', role: 'Service Manager', color: '#10B981', userTypeId: managerTypeId, payRate: 85.00, email: 'ryan@apexpowerservices.com.au', phone: '0423 344 556', username: 'ryan', password: '123456' },\n { id: `${companyId}_tech_3`, name: 'Sandra Okafor', role: 'Electrician', color: '#8B5CF6', userTypeId: techTypeId, payRate: 80.00, email: 'sandra@apexpowerservices.com.au', phone: '0434 455 667', username: 'sandra', password: '123456' },\n { id: `${companyId}_tech_4`, name: 'Dean Caruso', role: 'Office Administrator',color: '#F59E0B', userTypeId: officeTypeId, payRate: 50.00, email: 'dean@apexpowerservices.com.au', phone: '0445 566 778', username: 'dean', password: '123456' }\n ];\n\n this.cache.technicians = defaultTechs;\n this.emit('technicians', defaultTechs);\n\n if (companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('technicians', defaultTechs);\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder('technicians', defaultTechs).catch(err => {\n console.error('Error writing seeded technicians to local folder:', err);\n });\n }\n return;\n }\n\n const dbPayload = defaultTechs.map(t => this.denormalizeRecord({ ...t, companyId }, 'technicians'));\n const { error } = await supabase.from('technicians').insert(dbPayload);\n if (error) console.error('Error seeding default technicians:', error);\n }\n\n async migrateLocalToCloud(companyId, adminUserId) {\n this.companyId = companyId;\n this.userId = adminUserId;\n\n // 1. Update the remote company settings with local companySettings\n const currentSettings = this.getSettings();\n const { error: compErr } = await supabase.from('companies').update({\n abn: currentSettings.abn || '',\n phone: currentSettings.phone || '',\n domain: currentSettings.domain || '',\n email: currentSettings.email || '',\n address: currentSettings.address || '',\n settings: currentSettings\n }).eq('id', companyId);\n if (compErr) console.error('Error updating cloud company settings during migration:', compErr);\n\n // 2. Loop through all collections and push to Supabase\n // Skip 'companies' and 'technicians'\n const collectionsToMigrate = Object.keys(TABLE_MAP).filter(col => col !== 'companies' && col !== 'technicians');\n\n for (const col of collectionsToMigrate) {\n const items = this.cache[col] || [];\n if (items.length === 0) continue;\n\n const table = TABLE_MAP[col];\n if (!table) continue;\n\n // Assign the new cloud company_id and denormalize\n const payload = items.map(item => {\n const itemCopy = { ...item, companyId };\n return this.denormalizeRecord(itemCopy, col);\n });\n\n // Write to Supabase using upsert\n const { error } = await supabase.from(table).upsert(payload);\n if (error) {\n console.error(`Error migrating collection ${col}:`, error);\n throw new Error(`Failed to migrate ${col} data: ${error.message}`);\n }\n }\n\n // 3. Clear/close local DB connection and sync from Cloud\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n }\n\n deleteLocalAccountData(accountId) {\n return new Promise((resolve) => {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n if (typeof localStorage !== 'undefined') {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem(`relay_${accountId}_${col}`);\n });\n localStorage.removeItem(`relay_${accountId}_folder_sync_enabled`);\n localStorage.removeItem(`relay_${accountId}_seeded`);\n localStorage.removeItem(`relay_${accountId}_settings`);\n }\n if (typeof window !== 'undefined' && window.indexedDB) {\n const req = window.indexedDB.deleteDatabase(`RelayDispatchDB_${accountId}`);\n req.onsuccess = () => resolve();\n req.onerror = () => resolve();\n req.onblocked = () => {\n console.warn('Database deletion blocked, resolving anyway');\n resolve();\n };\n } else {\n resolve();\n }\n });\n }\n\n async clearAll() {\n this.clearSync();\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n \n if (activeAccount) {\n await this.deleteLocalAccountData(activeAccount);\n } else {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n this.dirHandle = null;\n try {\n await this.clearAllIndexedDB();\n } catch (err) {\n console.error('Error clearing IndexedDB:', err);\n }\n }\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "2bb7d63ba9ac7ff29abd2f143e33e734a309ddee": {"t": 1785754184.9157948, "n": 106730, "h": "f6b9232b18054eb1", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\nconst defaultLogoLarge = new URL('../assets/RELAY_Dispatch_Logo.png', import.meta.url).href;\nconst defaultLogoSmall = new URL('../assets/logo-small.png', import.meta.url).href;\n\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n passwordResetRequests: 'password_reset_requests',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents',\n leads: 'leads',\n schedule: 'schedule',\n projects: 'projects',\n costCenters: 'cost_centers'\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"deactivated\",\n \"deactivated_at\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"project_id\",\n \"cost_center_id\",\n \"created_at\",\n \"updated_at\"\n ],\n projects: [\n \"id\",\n \"company_id\",\n \"number\",\n \"name\",\n \"customer_id\",\n \"customer_name\",\n \"site_address\",\n \"status\",\n \"description\",\n \"start_date\",\n \"end_date\",\n \"created_at\",\n \"updated_at\"\n ],\n costCenters: [\n \"id\",\n \"company_id\",\n \"name\",\n \"code\",\n \"active\",\n \"xero_sales_account_code\",\n \"xero_expense_account_code\",\n \"xero_tracking_category_name\",\n \"xero_tracking_option_name\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"license_number\",\n \"hourly_rate\",\n \"after_hours_rate\",\n \"callout_fee\",\n \"specialties\",\n \"notes\",\n \"portal_token\",\n \"compliance_docs\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"address\",\n \"category\",\n \"account_number\",\n \"payment_terms\",\n \"notes\",\n \"attachments\",\n \"created_at\",\n \"updated_at\"\n ],\n leads: [\n \"id\",\n \"company_id\",\n \"number\",\n \"title\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"status\",\n \"source\",\n \"value\",\n \"description\",\n \"priority\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"created_at\",\n \"updated_at\"\n ],\n password_reset_requests: [\n \"id\",\n \"technician_id\",\n \"employee_id\",\n \"requested_at\",\n \"status\",\n \"token\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"type\",\n \"description\",\n \"priority\",\n \"read\",\n \"number\",\n \"asset_id\",\n \"job_id\",\n \"due_date\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g><text x=\"90\" y=\"36\" fill=\"#2D3134\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"25\" font-weight=\"900\" letter-spacing=\"0.04em\">DISPATCH</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nconst MODULE_PERMS = {\n 'Dashboard': [{ key: 'view' }],\n 'Customers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_contacts' }],\n 'Leads': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'convert' }],\n 'Quotes': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'approve' }, { key: 'convert' }, { key: 'generate_pdf' }],\n 'Jobs': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_tasks' }, { key: 'book_time' }, { key: 'view_costs' }, { key: 'view_quotes_tab' }, { key: 'view_pos_tab' }, { key: 'view_timesheets_tab' }, { key: 'view_invoices_tab' }, { key: 'manage_materials' }, { key: 'create_invoice' }],\n 'Timesheets': [{ key: 'view_own' }, { key: 'view' }, { key: 'create' }, { key: 'approve' }, { key: 'edit_all' }, { key: 'export' }],\n 'Assets': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Schedule': [{ key: 'view_own' }, { key: 'view' }, { key: 'edit' }],\n 'Contractors': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }],\n 'Suppliers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Stock': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Purchase Orders': [{ key: 'view' }, { key: 'create' }, { key: 'approve' }],\n 'Invoices': [{ key: 'view' }, { key: 'create' }, { key: 'send' }, { key: 'void' }],\n 'Reports': [{ key: 'view' }, { key: 'export' }],\n 'Documents': [{ key: 'view' }, { key: 'upload' }],\n 'Settings': [{ key: 'view' }, { key: 'edit_company' }, { key: 'manage_users' }, { key: 'manage_tax' }]\n};\n\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n this.db = null;\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n this.backupDirHandle = null;\n this.backupDirPermissionGranted = false;\n\n // Check if we have a cloud user at boot (before filling cache)\n const bootUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n const isCloudUser = !!(bootUser && bootUser.companyId && !bootUser.companyId.startsWith('acct_'));\n\n if (!isCloudUser && bootUser && bootUser.companyId && typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', bootUser.companyId);\n }\n\n // Pre-initialize empty arrays for collections.\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Load local settings if they exist (only for offline/demo mode)\n if (!isCloudUser && typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId && !this.companyId.startsWith('acct_')) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n // Only clear sync if we were previously in cloud sync mode or companyId is not defined\n if (!this.companyId || !this.companyId.startsWith('acct_')) {\n this.clearSync();\n }\n }\n });\n\n // Auto-trigger sync or local load if user already logged in at boot\n if (isCloudUser) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n this.initPromise = this.initializeCloudSync();\n } else {\n if (bootUser && bootUser.companyId) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n }\n this.initPromise = this.initializeLocalStore();\n }\n }\n\n getStorageKey(collection) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (activeAccount) {\n return `relay_${activeAccount}_${collection}`;\n }\n return `simpro_${collection}`;\n }\n\n getDBName() {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n return activeAccount ? `RelayDispatchDB_${activeAccount}` : 'RelayDispatchDB';\n }\n\n async initializeUser(user) {\n if (user && user.companyId && !user.companyId.startsWith('acct_')) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n } else {\n this.clearSync();\n if (user && user.companyId) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', user.companyId);\n }\n } else {\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n }\n this.initPromise = this.initializeLocalStore();\n await this.initPromise;\n }\n }\n\n async initializeLocalStore() {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n\n try {\n await this.initIndexedDB();\n } catch (e) {\n console.error('Failed to initialize IndexedDB, falling back to empty arrays:', e);\n }\n\n // Load local settings if they exist (namespaced)\n this.companySettings = null;\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n this.emit('settings', this.getSettings());\n\n // Seed default user types and forms if empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n if (this.cache.technicians.length === 0) {\n await this.seedDefaultTechnicians();\n }\n }\n\n initIndexedDB() {\n return new Promise((resolve, reject) => {\n if (typeof window === 'undefined' || !window.indexedDB) {\n return resolve();\n }\n\n const dbName = this.getDBName();\n const request = window.indexedDB.open(dbName, 3);\n\n request.onerror = (e) => {\n console.error('IndexedDB open error:', e.target.error);\n reject(e.target.error);\n };\n\n request.onsuccess = async (e) => {\n this.db = e.target.result;\n \n // 1. Load custom directory sync config from IndexedDB config store if it exists\n try {\n if (this.db.objectStoreNames.contains('config')) {\n await new Promise((resConfig) => {\n const transaction = this.db.transaction('config', 'readonly');\n const configStore = transaction.objectStore('config');\n \n const getSync = new Promise((resolve) => {\n const req = configStore.get('directory_handle');\n req.onsuccess = () => {\n console.log('[DEBUG Store] getSync onsuccess result:', req.result);\n if (req.result) {\n this.dirHandle = req.result.value;\n console.log('[DEBUG Store] loaded dirHandle:', this.dirHandle ? this.dirHandle.name : null);\n }\n resolve();\n };\n req.onerror = (e) => {\n console.error('[DEBUG Store] getSync onerror:', e);\n resolve();\n };\n });\n\n const getBackup = new Promise((resolve) => {\n const req = configStore.get('backup_directory_handle');\n req.onsuccess = () => {\n if (req.result) this.backupDirHandle = req.result.value;\n resolve();\n };\n req.onerror = () => resolve();\n });\n\n Promise.all([getSync, getBackup]).then(() => resConfig());\n });\n }\n } catch (err) {\n console.warn('Failed to read config from IndexedDB:', err);\n }\n\n // Verify directory permission silently on boot if handle exists\n if (this.dirHandle) {\n try {\n // Check permission silently without prompt dialog\n const opts = { mode: 'readwrite' };\n const status = await this.dirHandle.queryPermission(opts);\n this.folderSyncPermissionGranted = status === 'granted';\n } catch (err) {\n console.warn('Failed to query directory permission on boot:', err);\n }\n }\n\n // Verify backup directory permission silently on boot if handle exists\n if (this.backupDirHandle) {\n try {\n const opts = { mode: 'readwrite' };\n const status = await this.backupDirHandle.queryPermission(opts);\n this.backupDirPermissionGranted = status === 'granted';\n\n // Auto-trigger backup if granted and > 7 days since last backup\n if (this.backupDirPermissionGranted) {\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;\n if (!lastBackup || new Date(lastBackup).getTime() < sevenDaysAgo) {\n this.backupToFolder().catch(err => {\n console.error('Auto background backup failed:', err);\n });\n }\n }\n } catch (err) {\n console.warn('Failed to query backup directory permission on boot:', err);\n }\n }\n\n // Load all collections into cache\n const collections = Object.keys(TABLE_MAP);\n const loadPromises = collections.map(async (col) => {\n try {\n this.cache[col] = await this.readAllFromIndexedDB(col);\n } catch (err) {\n console.error(`Error loading collection ${col} from IndexedDB:`, err);\n this.cache[col] = [];\n }\n });\n\n await Promise.all(loadPromises);\n\n // Perform migration if we have old data in localStorage (only if not in a namespaced local account profile)\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (!activeAccount) {\n const migratePromises = collections.map(async (col) => {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n const items = JSON.parse(localData);\n if (Array.isArray(items) && items.length > 0) {\n // Write all items to IndexedDB\n await this.writeAllToIndexedDB(col, items);\n // Merge into cache if cache is empty\n if (this.cache[col].length === 0) {\n this.cache[col] = items;\n }\n }\n localStorage.removeItem('simpro_' + col);\n } catch (err) {\n console.error(`Migration error for ${col}:`, err);\n }\n }\n });\n\n await Promise.all(migratePromises);\n }\n resolve();\n };\n\n request.onupgradeneeded = (e) => {\n const db = e.target.result;\n const collections = Object.keys(TABLE_MAP);\n collections.forEach(col => {\n if (!db.objectStoreNames.contains(col)) {\n db.createObjectStore(col, { keyPath: 'id' });\n }\n });\n if (!db.objectStoreNames.contains('config')) {\n db.createObjectStore('config', { keyPath: 'key' });\n }\n };\n });\n }\n\n readAllFromIndexedDB(storeName) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve([]);\n try {\n const transaction = this.db.transaction(storeName, 'readonly');\n const store = transaction.objectStore(storeName);\n const getAllRequest = store.getAll();\n\n getAllRequest.onsuccess = () => {\n resolve(getAllRequest.result || []);\n };\n\n getAllRequest.onerror = (e) => {\n reject(e.target.error);\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeRecordToIndexedDB(storeName, record) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const putRequest = store.put(record);\n\n putRequest.onsuccess = () => resolve();\n putRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n deleteRecordFromIndexedDB(storeName, id) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const deleteRequest = store.delete(id);\n\n deleteRequest.onsuccess = () => resolve();\n deleteRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeAllToIndexedDB(storeName, items) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n \n // Clear existing\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n \n clearRequest.onsuccess = () => {\n let count = 0;\n if (items.length === 0) return resolve();\n \n items.forEach(item => {\n const putRequest = store.put(item);\n putRequest.onerror = (e) => reject(e.target.error);\n putRequest.onsuccess = () => {\n count++;\n if (count === items.length) {\n resolve();\n }\n };\n });\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n clearAllIndexedDB() {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const collections = Object.keys(TABLE_MAP);\n const transaction = this.db.transaction(collections, 'readwrite');\n let count = 0;\n \n collections.forEach(col => {\n const store = transaction.objectStore(col);\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n clearRequest.onsuccess = () => {\n count++;\n if (count === collections.length) {\n resolve();\n }\n };\n });\n } catch (err) {\n reject(err);\n }\n });\n }\n\n // ── Local Folder Sync / File System Access Driver ──────────────────────────\n\n async verifyDirPermission(readWrite) {\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n this.folderSyncPermissionGranted = true;\n return true; // Capacitor native has permissions checked at app level\n }\n\n if (!this.dirHandle) return false;\n const opts = {};\n if (readWrite) opts.mode = 'readwrite';\n \n try {\n const currentPerm = await this.dirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.folderSyncPermissionGranted = true;\n return true;\n }\n \n // Request permission (must be called within a user gesture handler)\n const newPerm = await this.dirHandle.requestPermission(opts);\n this.folderSyncPermissionGranted = newPerm === 'granted';\n return this.folderSyncPermissionGranted;\n } catch (e) {\n this.folderSyncPermissionGranted = false;\n return false;\n }\n }\n\n async verifyBackupDirPermission(requestInteractive) {\n if (!this.backupDirHandle) return false;\n const opts = { mode: 'readwrite' };\n try {\n const currentPerm = await this.backupDirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.backupDirPermissionGranted = true;\n return true;\n }\n if (requestInteractive) {\n const newPerm = await this.backupDirHandle.requestPermission(opts);\n this.backupDirPermissionGranted = newPerm === 'granted';\n return this.backupDirPermissionGranted;\n }\n this.backupDirPermissionGranted = false;\n return false;\n } catch (e) {\n this.backupDirPermissionGranted = false;\n return false;\n }\n }\n\n // Ensure a subfolder for the current company exists within the selected directory.\n async ensureCompanyFolderHandle(customDirHandle) {\n const baseHandle = customDirHandle || this.dirHandle;\n if (!baseHandle) return null;\n const rawName = this.companySettings?.name || this.companySettings?.id || 'Company';\n const safeName = rawName.replace(/[\\\\/:*?\"<>|]/g, '_');\n try {\n const companyHandle = await baseHandle.getDirectoryHandle(safeName, { create: true });\n return companyHandle;\n } catch (e) {\n console.error('Failed to get/create company folder:', e);\n return null;\n }\n }\n\n async setBackupDirectory(dirHandle) {\n this.backupDirHandle = dirHandle || null;\n this.backupDirPermissionGranted = !!dirHandle;\n \n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n if (dirHandle) {\n const putReq = configStore.put({ key: 'backup_directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = () => resolve();\n } else {\n const deleteReq = configStore.delete('backup_directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n }\n });\n }\n }\n\n async backupToFolder(dirHandle) {\n const handle = dirHandle || this.backupDirHandle;\n if (!handle) throw new Error('No backup directory configured.');\n \n const currentPerm = await handle.queryPermission({ mode: 'readwrite' });\n if (currentPerm !== 'granted') {\n const newPerm = await handle.requestPermission({ mode: 'readwrite' });\n if (newPerm !== 'granted') {\n throw new Error('Write permission denied for backup directory.');\n }\n }\n \n // Ensure company-specific folder exists within the selected directory\n const companyFolderHandle = await this.ensureCompanyFolderHandle(dirHandle);\n const dataDir = await companyFolderHandle.getDirectoryHandle('data', { create: true });\n const collections = Object.keys(TABLE_MAP);\n for (const col of collections) {\n const items = this.cache[col] || [];\n const fileHandle = await dataDir.getFileHandle(`${col}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items, null, 2));\n await writable.close();\n }\n \n // Update last backup timestamp\n localStorage.setItem('relay_last_backup_time', new Date().toISOString());\n this.backupDirPermissionGranted = true;\n }\n\n async setLocalDirectory(dirHandle) {\n this.dirHandle = dirHandle;\n if (dirHandle) {\n this.folderSyncEnabled = true;\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('folder_sync_enabled'), 'true');\n }\n \n // Save handle to IndexedDB\n if (this.db) {\n await new Promise((resolve, reject) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const putReq = configStore.put({ key: 'directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = (e) => reject(e.target.error);\n });\n }\n \n this.folderSyncPermissionGranted = true;\n \n // Force rewrite all cached collections to directory immediately\n const collections = Object.keys(TABLE_MAP);\n await Promise.all(collections.map(col => this.writeCollectionToFolder(col, this.cache[col])));\n } else {\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n \n // Remove handle from IndexedDB\n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const deleteReq = configStore.delete('directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n });\n }\n }\n }\n\n async writeCollectionToFolder(collection, items) {\n if (!this.folderSyncEnabled) return;\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/data/${collection}.json`,\n data: JSON.stringify(items || [], null, 2),\n directory: 'DOCUMENTS',\n encoding: 'utf8',\n recursive: true\n });\n } catch (err) {\n console.error(`Capacitor failed to write collection ${collection}:`, err);\n }\n return;\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return;\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return;\n const dataDir = await companyFolder.getDirectoryHandle('data', { create: true });\n const fileHandle = await dataDir.getFileHandle(`${collection}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items || [], null, 2));\n await writable.close();\n } catch (err) {\n console.error(`Failed to write collection ${collection} to local directory:`, err);\n }\n }\n\n async writeDocumentFileToFolder(docId, name, dataUrl) {\n if (!this.folderSyncEnabled) return null;\n\n const ext = name.includes('.') ? name.split('.').pop() : 'bin';\n const fileName = `${docId}.${ext}`;\n const relativePath = `documents/${fileName}`;\n\n // Extract base64 payload\n const base64Parts = dataUrl.split(',');\n if (base64Parts.length < 2) return null;\n const base64Data = base64Parts[1];\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/documents/${fileName}`,\n data: base64Data,\n directory: 'DOCUMENTS',\n recursive: true\n });\n return relativePath;\n } catch (err) {\n console.error('Capacitor failed to write document:', err);\n return null;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return null;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return null;\n\n const mimeMatch = base64Parts[0].match(/:(.*?);/);\n const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';\n const binaryStr = atob(base64Data);\n const len = binaryStr.length;\n const bytes = new Uint8Array(len);\n for (let i = 0; i < len; i++) {\n bytes[i] = binaryStr.charCodeAt(i);\n }\n const blob = new Blob([bytes], { type: mime });\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return null;\n const docsDir = await companyFolder.getDirectoryHandle('documents', { create: true });\n const fileHandle = await docsDir.getFileHandle(fileName, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(blob);\n await writable.close();\n\n return relativePath;\n } catch (err) {\n console.error('Failed to save document file to local directory:', err);\n return null;\n }\n }\n\n async getDocumentUrl(doc) {\n if (!doc || !doc.url) return '';\n if (doc.url.startsWith('data:') || doc.url.startsWith('http')) {\n return doc.url;\n }\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n const result = await Filesystem.readFile({\n path: `RelayDispatchData/${doc.url}`,\n directory: 'DOCUMENTS'\n });\n const mime = doc.type || 'application/octet-stream';\n return `data:${mime};base64,${result.data}`;\n } catch (err) {\n console.error('Capacitor failed to read document file:', err);\n throw err;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) {\n throw new Error('Local directory sync is not configured.');\n }\n\n try {\n const hasPerm = await this.verifyDirPermission(false);\n if (!hasPerm) {\n throw new Error('Permission to access the local directory was not granted.');\n }\n\n const parts = doc.url.split('/');\n const dirName = parts[0];\n const fileName = parts[1];\n\n const dir = await this.dirHandle.getDirectoryHandle(dirName);\n const fileHandle = await dir.getFileHandle(fileName);\n const file = await fileHandle.getFile();\n return URL.createObjectURL(file);\n } catch (err) {\n console.error('Failed to read file from local directory:', err);\n throw err;\n }\n }\n\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // Initialize IndexedDB for cloud users too, to store backup directory handles\n await this.initIndexedDB();\n\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id\n query = query.eq('company_id', this.companyId);\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n await this.writeAllToIndexedDB(col, this.cache[col]);\n }\n });\n\n await Promise.all(promises);\n\n // Seed default user types if database is empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n // Unique channel name per subscribe — reusing a fixed name throws\n // \"cannot add postgres_changes callbacks ... after subscribe()\" when sync re-inits.\n const channel = supabase\n .channel(`table-db-changes-${this.companyId || 'anon'}-${Date.now()}`)\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n if (collection === 'contractors') {\n record.businessName = record.name;\n record.active = record.status === 'Active';\n }\n\n if (collection === 'suppliers') {\n record.active = record.status === 'Active';\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.project_id !== undefined) {\n record.projectId = record.project_id;\n delete record.project_id;\n }\n if (record.cost_center_id !== undefined) {\n record.costCenterId = record.cost_center_id;\n delete record.cost_center_id;\n }\n if (record.xero_sales_account_code !== undefined) {\n record.xeroSalesAccountCode = record.xero_sales_account_code;\n delete record.xero_sales_account_code;\n }\n if (record.xero_expense_account_code !== undefined) {\n record.xeroExpenseAccountCode = record.xero_expense_account_code;\n delete record.xero_expense_account_code;\n }\n if (record.xero_tracking_category_name !== undefined) {\n record.xeroTrackingCategoryName = record.xero_tracking_category_name;\n delete record.xero_tracking_category_name;\n }\n if (record.xero_tracking_option_name !== undefined) {\n record.xeroTrackingOptionName = record.xero_tracking_option_name;\n delete record.xero_tracking_option_name;\n }\n if (record.deactivated_at !== undefined) {\n record.deactivatedAt = record.deactivated_at;\n delete record.deactivated_at;\n }\n if (record.start_date !== undefined) {\n record.startDate = record.start_date;\n delete record.start_date;\n }\n if (record.end_date !== undefined) {\n record.endDate = record.end_date;\n delete record.end_date;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.license_number !== undefined) { record.licenseNumber = record.license_number; delete record.license_number; }\n if (record.hourly_rate !== undefined) { record.hourlyRate = parseFloat(record.hourly_rate); delete record.hourly_rate; }\n if (record.after_hours_rate !== undefined) { record.afterHoursRate = parseFloat(record.after_hours_rate); delete record.after_hours_rate; }\n if (record.callout_fee !== undefined) { record.calloutFee = parseFloat(record.callout_fee); delete record.callout_fee; }\n if (record.compliance_docs !== undefined) { record.complianceDocs = record.compliance_docs; delete record.compliance_docs; }\n if (record.account_number !== undefined) { record.accountNumber = record.account_number; delete record.account_number; }\n if (record.payment_terms !== undefined) { record.paymentTerms = record.payment_terms; delete record.payment_terms; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.day_offset !== undefined) { record.dayOffset = parseInt(record.day_offset); delete record.day_offset; }\n if (record.start_hour !== undefined) { record.startHour = parseFloat(record.start_hour); delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = parseFloat(record.end_hour); delete record.end_hour; }\n\n if (record.color && record.color.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.color.substring(9));\n record.taskId = parsed.taskId;\n record.taskPath = parsed.taskId;\n record.taskName = parsed.taskName;\n record.color = parsed.color;\n if (parsed.date !== undefined) record.date = parsed.date;\n if (parsed.startTime !== undefined) record.startTime = parsed.startTime;\n if (parsed.finishTime !== undefined) record.finishTime = parsed.finishTime;\n if (parsed.hours !== undefined) record.hours = parsed.hours;\n if (parsed.startHour !== undefined && parsed.startHour !== null) record.startHour = parsed.startHour;\n if (parsed.endHour !== undefined && parsed.endHour !== null) record.endHour = parsed.endHour;\n if (parsed.dayOffset !== undefined && parsed.dayOffset !== null) record.dayOffset = parsed.dayOffset;\n } catch (e) {\n console.error(e);\n }\n }\n if (collection === 'invoices') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.invoiceType = 'Standard';\n record.laborProfileId = '';\n record.issueDate = '';\n record.originalQuoteId = '';\n record.originalQuoteNumber = '';\n record.originalSubtotal = 0;\n record.approvedVariationsSum = 0;\n record.pendingVariationsSum = 0;\n record.totalInternalCost = 0;\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.invoiceType = meta.invoiceType || 'Standard';\n record.laborProfileId = meta.laborProfileId || '';\n record.issueDate = meta.issueDate || '';\n record.originalQuoteId = meta.originalQuoteId || '';\n record.originalQuoteNumber = meta.originalQuoteNumber || '';\n record.originalSubtotal = meta.originalSubtotal || 0;\n record.approvedVariationsSum = meta.approvedVariationsSum || 0;\n record.pendingVariationsSum = meta.pendingVariationsSum || 0;\n record.totalInternalCost = meta.totalInternalCost || 0;\n record.jobIds = meta.jobIds || []; // consolidated invoices link multiple jobs\n }\n }\n if (collection === 'quotes') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.laborProfileId = '';\n record.description = '';\n record.isTemplate = false;\n record.templateId = '';\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.laborProfileId = meta.laborProfileId || '';\n record.description = meta.description || '';\n record.isTemplate = meta.isTemplate || false;\n record.templateId = meta.templateId || '';\n }\n }\n if (collection === 'jobs') {\n if (record.notes && record.notes.startsWith('__meta__:')) {\n try {\n const meta = JSON.parse(record.notes.substring(9));\n record.isRecurring = meta.isRecurring || false;\n record.recurringConfig = meta.recurringConfig || null;\n record.preferredTime = meta.preferredTime || '';\n record.description = meta.description || '';\n record.parentJobId = meta.parentJobId || null;\n record.notes = meta.notes || '';\n } catch (e) {\n console.error('Error parsing jobs meta:', e);\n }\n }\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (collection === 'contractors') {\n record.name = record.businessName;\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (collection === 'suppliers') {\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.xeroSalesAccountCode !== undefined) {\n record.xero_sales_account_code = record.xeroSalesAccountCode;\n delete record.xeroSalesAccountCode;\n }\n if (record.xeroExpenseAccountCode !== undefined) {\n record.xero_expense_account_code = record.xeroExpenseAccountCode;\n delete record.xeroExpenseAccountCode;\n }\n if (record.xeroTrackingCategoryName !== undefined) {\n record.xero_tracking_category_name = record.xeroTrackingCategoryName;\n delete record.xeroTrackingCategoryName;\n }\n if (record.xeroTrackingOptionName !== undefined) {\n record.xero_tracking_option_name = record.xeroTrackingOptionName;\n delete record.xeroTrackingOptionName;\n }\n if (record.deactivatedAt !== undefined) {\n record.deactivated_at = record.deactivatedAt;\n delete record.deactivatedAt;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.projectId !== undefined) {\n record.project_id = record.projectId;\n delete record.projectId;\n }\n if (record.costCenterId !== undefined) {\n record.cost_center_id = record.costCenterId;\n delete record.costCenterId;\n }\n if (record.startDate !== undefined) {\n record.start_date = record.startDate;\n delete record.startDate;\n }\n if (record.endDate !== undefined) {\n record.end_date = record.endDate;\n delete record.endDate;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.licenseNumber !== undefined) { record.license_number = record.licenseNumber; delete record.licenseNumber; }\n if (record.hourlyRate !== undefined) { record.hourly_rate = record.hourlyRate; delete record.hourlyRate; }\n if (record.afterHoursRate !== undefined) { record.after_hours_rate = record.afterHoursRate; delete record.afterHoursRate; }\n if (record.calloutFee !== undefined) { record.callout_fee = record.calloutFee; delete record.calloutFee; }\n if (record.complianceDocs !== undefined) { record.compliance_docs = record.complianceDocs; delete record.complianceDocs; }\n if (record.accountNumber !== undefined) { record.account_number = record.accountNumber; delete record.accountNumber; }\n if (record.paymentTerms !== undefined) { record.payment_terms = record.paymentTerms; delete record.paymentTerms; }\n if (record.jobNumber !== undefined) { record.job_number = record.jobNumber; delete record.jobNumber; }\n if (record.dayOffset !== undefined) { record.day_offset = record.dayOffset; delete record.dayOffset; }\n if (record.startHour !== undefined) { record.start_hour = record.startHour; delete record.startHour; }\n if (record.endHour !== undefined) { record.end_hour = record.endHour; delete record.endHour; }\n\n if (collection === 'schedule') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || null,\n color: record.color || null,\n date: record.date || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n hours: record.hours || null,\n startHour: record.startHour !== undefined ? record.startHour : null,\n endHour: record.endHour !== undefined ? record.endHour : null,\n dayOffset: record.dayOffset !== undefined ? record.dayOffset : null\n };\n record.color = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'jobs') {\n const meta = {\n isRecurring: record.isRecurring || false,\n recurringConfig: record.recurringConfig || null,\n preferredTime: record.preferredTime || '',\n description: record.description || '',\n parentJobId: record.parentJobId || null,\n notes: record.notes || ''\n };\n record.notes = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'invoices') {\n const meta = {\n sections: record.sections || [],\n invoiceType: record.invoiceType || 'Standard',\n laborProfileId: record.laborProfileId || '',\n issueDate: record.issueDate || '',\n originalQuoteId: record.originalQuoteId || '',\n originalQuoteNumber: record.originalQuoteNumber || '',\n originalSubtotal: record.originalSubtotal || 0,\n approvedVariationsSum: record.approvedVariationsSum || 0,\n pendingVariationsSum: record.pendingVariationsSum || 0,\n totalInternalCost: record.totalInternalCost || 0,\n jobIds: record.jobIds || [] // consolidated invoices link multiple jobs\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n if (collection === 'quotes') {\n const meta = {\n sections: record.sections || [],\n laborProfileId: record.laborProfileId || '',\n description: record.description || '',\n isTemplate: record.isTemplate || false,\n templateId: record.templateId || ''\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n const items = this.cache[collection] || [];\n if (collection === 'technicians') {\n const loginMode = typeof localStorage !== 'undefined' ? localStorage.getItem('relay_login_mode') : null;\n if (loginMode === 'local') {\n const currentUserStr = typeof localStorage !== 'undefined' ? localStorage.getItem('currentUser') : null;\n if (currentUserStr) {\n const currentUser = JSON.parse(currentUserStr);\n if (currentUser && currentUser.id) {\n return [{\n id: currentUser.id,\n name: currentUser.name || 'Local Admin',\n role: 'Administrator',\n color: currentUser.color || '#FF5C00',\n userTypeId: currentUser.userTypeId,\n email: currentUser.email || '',\n username: currentUser.username || ''\n }];\n }\n }\n }\n }\n return items;\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Surface a failed background write to the user instead of failing silently.\n // (A silent insert/update failure leaves the optimistic cache entry in place, so the\n // record looks saved until the next reload — the classic \"it didn't save\" symptom.)\n _notifyWriteError(action, collection, error) {\n console.error(`Error trying to ${action} ${collection}:`, error?.message || error, error);\n try {\n import('../components/Notifications.js')\n .then(({ showToast }) => showToast(\n `Couldn't ${action} ${collection.replace(/s$/, '')} — ${error?.message || 'database error'}`,\n 'error'))\n .catch(() => {});\n } catch (e) {}\n }\n\n // All jobs an invoice bills: single jobId and/or consolidated jobIds\n _invoiceJobIds(inv) {\n return [inv?.jobId, ...(inv?.jobIds || [])].filter(Boolean);\n }\n\n // Invoice lifecycle → job status sync. Jobs are only marked \"Invoiced\" when their\n // invoice actually leaves Draft (Sent/Paid/etc.) — NOT when a draft is created, so\n // abandoning/deleting a draft never strands jobs in the wrong status.\n _markJobsInvoiced(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (job && job.status !== 'Invoiced') this.update('jobs', jid, { status: 'Invoiced' });\n });\n }\n\n // When an invoice is deleted, revert any of its jobs still marked \"Invoiced\" —\n // unless another invoice also bills that job.\n _revertJobsOnInvoiceDelete(inv) {\n this._invoiceJobIds(inv).forEach(jid => {\n const job = this.getById('jobs', jid);\n if (!job || job.status !== 'Invoiced') return;\n const billedElsewhere = (this.cache.invoices || []).some(other =>\n other.id !== inv.id && this._invoiceJobIds(other).includes(jid));\n if (!billedElsewhere) this.update('jobs', jid, { status: 'Completed' });\n });\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = this.getNextNumber('LD-', 'leads');\n }\n if (collection === 'notifications' && !item.number) {\n item.number = this.getNextNumber('NT-', 'notifications');\n }\n if (collection === 'invoices' && !item.number) {\n item.number = this.getNextNumber('INV-', 'invoices');\n }\n if (collection === 'quotes' && !item.number) {\n item.number = this.getNextNumber('Q-', 'quotes');\n }\n if (collection === 'jobs' && !item.number) {\n item.number = this.getNextNumber('J-', 'jobs');\n }\n if (collection === 'purchaseOrders' && !item.number) {\n item.number = this.getNextNumber('PO-', 'purchaseOrders');\n }\n if (collection === 'projects' && !item.number) {\n item.number = this.getNextNumber('PRJ-', 'projects');\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Rare path: an invoice created already past Draft marks its jobs immediately\n if (collection === 'invoices' && item.status && item.status !== 'Draft' && item.status !== 'Void') {\n this._markJobsInvoiced(item);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Local Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (this.folderSyncEnabled) {\n if (collection === 'documents' && item.url && item.url.startsWith('data:')) {\n this.writeDocumentFileToFolder(item.id, item.name, item.url).then(relPath => {\n if (relPath) {\n item.url = relPath;\n // Re-save in IndexedDB and rewrite folder collection with updated path\n this.writeRecordToIndexedDB(collection, item);\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n });\n } else {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n }\n this.writeRecordToIndexedDB(collection, item).catch(err => {\n console.error(`Error saving ${collection} to IndexedDB:`, err);\n });\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n item.email = email;\n\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) {\n // Roll back the optimistic add so the UI matches the database\n this.cache[collection] = (this.cache[collection] || []).filter(x => x.id !== item.id);\n this.emit(collection, this.cache[collection]);\n this._notifyWriteError('save', collection, error);\n }\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n // Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n this.writeRecordToIndexedDB(collection, updated).catch(err => {\n console.error(`Error saving updated ${collection} to IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const isAuthUpdate = updates.username !== undefined ||\n updates.email !== undefined ||\n updates.password !== undefined ||\n updates.name !== undefined ||\n updates.role !== undefined ||\n updates.userTypeId !== undefined ||\n updates.color !== undefined ||\n updates.payRate !== undefined;\n\n if (isAuthUpdate) {\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to update user.');\n }\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n const dbPayload = this.denormalizeRecord(updated, 'technicians');\n const { error: dbError } = await supabase\n .from('profiles')\n .update(dbPayload)\n .eq('id', id);\n\n if (dbError) {\n throw dbError;\n }\n\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 2b. Invoice leaves Draft → now (and only now) its jobs become \"Invoiced\"\n if (collection === 'invoices' && updates.status && previous.status === 'Draft'\n && updates.status !== 'Draft' && updates.status !== 'Void') {\n this._markJobsInvoiced(updated);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Roll back to the previous version so the UI matches the database\n const arr = [...(this.cache[collection] || [])];\n const i = arr.findIndex(x => x.id === id);\n if (i !== -1) { arr[i] = previous; this.cache[collection] = arr; this.emit(collection, arr); }\n this._notifyWriteError('update', collection, error);\n }\n });\n }\n\n return updated;\n }\n\n // Child records that must not outlive their parent. Deleting a parent cascades to\n // these (scheduling/alert artifacts). Financial/labor records (invoices, timesheets)\n // are deliberately KEPT — they're history and carry their own denormalised numbers.\n _cascadeDelete(collection, id) {\n const CASCADES = {\n jobs: [ ['schedule', 'jobId'], ['formInstances', 'jobId'], ['notifications', 'jobId'] ],\n assets: [ ['maintenancePlans', 'assetId'], ['notifications', 'assetId'] ],\n formTemplates: [ ['formInstances', 'templateId'] ],\n };\n (CASCADES[collection] || []).forEach(([childCol, fk]) => {\n (this.cache[childCol] || []).filter(c => c[fk] === id).forEach(c => this.delete(childCol, c.id));\n });\n }\n\n delete(collection, id) {\n // 0. Cascade-remove dependent child records BEFORE the parent leaves the cache\n this._cascadeDelete(collection, id);\n\n // 1. Update memory cache (keep a copy of the removed row for rollback)\n const removed = (this.cache[collection] || []).find(item => item.id === id);\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Deleting an invoice frees any jobs it (alone) had marked \"Invoiced\"\n if (collection === 'invoices' && removed) {\n this._revertJobsOnInvoiceDelete(removed);\n }\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n this.deleteRecordFromIndexedDB(collection, id).catch(err => {\n console.error(`Error deleting ${id} from ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Restore the row we optimistically removed\n if (removed) { this.cache[collection] = [...(this.cache[collection] || []), removed]; this.emit(collection, this.cache[collection]); }\n this._notifyWriteError('delete', collection, error);\n }\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n /**\n * Generate the next sequential number for a collection.\n * Scans existing records, finds the highest numeric suffix, and returns prefix + (max+1) zero-padded to 5 digits.\n * e.g. getNextNumber('INV-', 'invoices') → 'INV-00001', 'INV-00002', ...\n */\n getNextNumber(defaultPrefix, collection) {\n const settings = this.getSettings();\n const dt = settings.documentTheme || {};\n \n let prefix = defaultPrefix;\n let startingNum = 1;\n\n if (collection === 'invoices') {\n prefix = dt.invoicePrefix !== undefined ? dt.invoicePrefix : 'INV-';\n startingNum = dt.invoiceStartingNumber !== undefined ? parseInt(dt.invoiceStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n } else if (collection === 'quotes') {\n prefix = dt.quotePrefix !== undefined ? dt.quotePrefix : 'Q-';\n startingNum = dt.quoteStartingNumber !== undefined ? parseInt(dt.quoteStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n }\n\n const items = this.getAll(collection) || [];\n let maxNum = startingNum - 1;\n\n items.forEach(item => {\n if (item.number && typeof item.number === 'string' && item.number.startsWith(prefix)) {\n const numStr = item.number.slice(prefix.length);\n const num = parseInt(numStr, 10);\n if (!isNaN(num) && num > maxNum) {\n maxNum = num;\n }\n }\n });\n\n return prefix + (maxNum + 1).toString().padStart(5, '0');\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: defaultLogoLarge,\n logoSmall: defaultLogoSmall,\n markupPercent: 20,\n taxEnabled: true,\n taxRate: 10,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n jobTypes: ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'],\n supplierCategories: ['Electrical', 'Plumbing', 'HVAC', 'Fire Safety', 'Security', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n },\n ai: {\n enabled: (this.companyId && !this.companyId.startsWith('acct_')) ? !!(typeof import.meta.env !== 'undefined' && (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY)) : false,\n apiKey: (this.companyId && !this.companyId.startsWith('acct_')) ? (typeof import.meta.env !== 'undefined' ? (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY || '') : '') : '',\n endpoint: 'https://api.openai.com/v1/chat/completions',\n model: 'gpt-4o-mini',\n systemPrompt: 'You are Relay, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default logos if user has not uploaded custom ones\n if (!merged.logo || merged.logo.includes('logo-large.png')) merged.logo = defaultLogoLarge;\n if (!merged.logoSmall) merged.logoSmall = defaultLogoSmall;\n if (!merged.jobTypes) merged.jobTypes = [...defaultSettings.jobTypes];\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n merged.ai = { ...defaultSettings.ai, ...this.companySettings.ai };\n return merged;\n }\n\n return defaultSettings;\n }\n\n getTaxRate() {\n const settings = this.getSettings();\n if (settings.taxEnabled === false) return 0;\n const rate = settings.taxRate !== undefined ? Number(settings.taxRate) : 10;\n return rate / 100;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('settings'), JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId && !this.companyId.startsWith('acct_')) return true;\n return localStorage.getItem(this.getStorageKey('seeded')) === 'true';\n }\n\n save(collection, items) {\n const prev = this.cache[collection] || [];\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const p = this.writeAllToIndexedDB(collection, items).catch(err => {\n console.error(`Error saving all items to ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, items);\n }\n return p;\n }\n\n // Cloud mode: persist the whole collection. Upsert every current row, then delete\n // any rows that were removed from the array. (Previously this only touched the\n // in-memory cache, so every save() — assign-tech, role edits, form templates,\n // portal edits, stock receiving, mark-read, etc. — silently failed to persist.)\n const table = TABLE_MAP[collection];\n if (!table) return; // cache-only collections (e.g. 'activity') have no table\n\n const payload = items\n .filter(it => it && it.id)\n .map(it => this.denormalizeRecord({ ...it, companyId: this.companyId }, collection));\n if (payload.length) {\n supabase.from(table).upsert(payload).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n\n const currentIds = new Set(items.map(it => it && it.id));\n const removedIds = prev.filter(p => p && p.id && !currentIds.has(p.id)).map(p => p.id);\n if (removedIds.length) {\n supabase.from(table).delete().in('id', removedIds).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n }\n\n markSeeded() {\n localStorage.setItem(this.getStorageKey('seeded'), 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('formTemplates', templates);\n return;\n }\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n async seedDefaultUserTypes() {\n if (!this.companyId) return;\n\n const userTypes = [\n {\n id: `${this.companyId}_ut_admin`,\n name: 'Admin',\n description: 'Full system access',\n permissions: buildGranularPerms(() => true)\n },\n {\n id: `${this.companyId}_ut_manager`,\n name: 'Manager',\n description: 'Can manage most workflows but limited settings access',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company', 'manage_tax'].includes(key);\n return true;\n })\n },\n {\n id: `${this.companyId}_ut_tech`,\n name: 'Technician',\n description: 'Field staff — limited to their own jobs, schedule and timesheets',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n {\n id: `${this.companyId}_ut_office`,\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders', 'Suppliers'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n\n this.cache.userTypes = userTypes;\n this.emit('userTypes', userTypes);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('userTypes', userTypes);\n return;\n }\n\n const dbPayload = userTypes.map(ut => this.denormalizeRecord({ ...ut, companyId: this.companyId }, 'userTypes'));\n const { error } = await supabase.from('user_types').insert(dbPayload);\n if (error) console.error('Error seeding default user types:', error);\n }\n\n async seedDefaultTechnicians() {\n const companyId = this.companyId;\n if (!companyId) return;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const managerTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_manager` : 'ut_manager';\n const techTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_tech` : 'ut_tech';\n const officeTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_office` : 'ut_office';\n\n const defaultTechs = [\n { id: `${companyId}_tech_1`, name: 'Jake Morrow', role: 'Senior Electrician', color: '#3B82F6', userTypeId: adminTypeId, payRate: 95.00, email: 'jake@apexpowerservices.com.au', phone: '0412 233 445', username: 'jake', password: '123456' },\n { id: `${companyId}_tech_2`, name: 'Ryan Holt', role: 'Service Manager', color: '#10B981', userTypeId: managerTypeId, payRate: 85.00, email: 'ryan@apexpowerservices.com.au', phone: '0423 344 556', username: 'ryan', password: '123456' },\n { id: `${companyId}_tech_3`, name: 'Sandra Okafor', role: 'Electrician', color: '#8B5CF6', userTypeId: techTypeId, payRate: 80.00, email: 'sandra@apexpowerservices.com.au', phone: '0434 455 667', username: 'sandra', password: '123456' },\n { id: `${companyId}_tech_4`, name: 'Dean Caruso', role: 'Office Administrator',color: '#F59E0B', userTypeId: officeTypeId, payRate: 50.00, email: 'dean@apexpowerservices.com.au', phone: '0445 566 778', username: 'dean', password: '123456' }\n ];\n\n this.cache.technicians = defaultTechs;\n this.emit('technicians', defaultTechs);\n\n if (companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('technicians', defaultTechs);\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder('technicians', defaultTechs).catch(err => {\n console.error('Error writing seeded technicians to local folder:', err);\n });\n }\n return;\n }\n\n const dbPayload = defaultTechs.map(t => this.denormalizeRecord({ ...t, companyId }, 'technicians'));\n const { error } = await supabase.from('technicians').insert(dbPayload);\n if (error) console.error('Error seeding default technicians:', error);\n }\n\n async migrateLocalToCloud(companyId, adminUserId) {\n this.companyId = companyId;\n this.userId = adminUserId;\n\n // 1. Update the remote company settings with local companySettings\n const currentSettings = this.getSettings();\n const { error: compErr } = await supabase.from('companies').update({\n abn: currentSettings.abn || '',\n phone: currentSettings.phone || '',\n domain: currentSettings.domain || '',\n email: currentSettings.email || '',\n address: currentSettings.address || '',\n settings: currentSettings\n }).eq('id', companyId);\n if (compErr) console.error('Error updating cloud company settings during migration:', compErr);\n\n // 2. Loop through all collections and push to Supabase\n // Skip 'companies' and 'technicians'\n const collectionsToMigrate = Object.keys(TABLE_MAP).filter(col => col !== 'companies' && col !== 'technicians');\n\n for (const col of collectionsToMigrate) {\n const items = this.cache[col] || [];\n if (items.length === 0) continue;\n\n const table = TABLE_MAP[col];\n if (!table) continue;\n\n // Assign the new cloud company_id and denormalize\n const payload = items.map(item => {\n const itemCopy = { ...item, companyId };\n return this.denormalizeRecord(itemCopy, col);\n });\n\n // Write to Supabase using upsert\n const { error } = await supabase.from(table).upsert(payload);\n if (error) {\n console.error(`Error migrating collection ${col}:`, error);\n throw new Error(`Failed to migrate ${col} data: ${error.message}`);\n }\n }\n\n // 3. Clear/close local DB connection and sync from Cloud\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n }\n\n deleteLocalAccountData(accountId) {\n return new Promise((resolve) => {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n if (typeof localStorage !== 'undefined') {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem(`relay_${accountId}_${col}`);\n });\n localStorage.removeItem(`relay_${accountId}_folder_sync_enabled`);\n localStorage.removeItem(`relay_${accountId}_seeded`);\n localStorage.removeItem(`relay_${accountId}_settings`);\n }\n if (typeof window !== 'undefined' && window.indexedDB) {\n const req = window.indexedDB.deleteDatabase(`RelayDispatchDB_${accountId}`);\n req.onsuccess = () => resolve();\n req.onerror = () => resolve();\n req.onblocked = () => {\n console.warn('Database deletion blocked, resolving anyway');\n resolve();\n };\n } else {\n resolve();\n }\n });\n }\n\n async clearAll() {\n this.clearSync();\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n \n if (activeAccount) {\n await this.deleteLocalAccountData(activeAccount);\n } else {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n this.dirHandle = null;\n try {\n await this.clearAllIndexedDB();\n } catch (err) {\n console.error('Error clearing IndexedDB:', err);\n }\n }\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "48f8c39a61093a40106157ace6133830b76b8d92": {"t": 1785754186.488053, "n": 104406, "h": "5b69f580b8a16ad2", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\nconst defaultLogoLarge = new URL('../assets/RELAY_Dispatch_Logo.png', import.meta.url).href;\nconst defaultLogoSmall = new URL('../assets/logo-small.png', import.meta.url).href;\n\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n passwordResetRequests: 'password_reset_requests',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents',\n leads: 'leads',\n schedule: 'schedule',\n projects: 'projects',\n costCenters: 'cost_centers'\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"deactivated\",\n \"deactivated_at\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"project_id\",\n \"cost_center_id\",\n \"created_at\",\n \"updated_at\"\n ],\n projects: [\n \"id\",\n \"company_id\",\n \"number\",\n \"name\",\n \"customer_id\",\n \"customer_name\",\n \"site_address\",\n \"status\",\n \"description\",\n \"start_date\",\n \"end_date\",\n \"created_at\",\n \"updated_at\"\n ],\n costCenters: [\n \"id\",\n \"company_id\",\n \"name\",\n \"code\",\n \"active\",\n \"xero_sales_account_code\",\n \"xero_expense_account_code\",\n \"xero_tracking_category_name\",\n \"xero_tracking_option_name\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"license_number\",\n \"hourly_rate\",\n \"after_hours_rate\",\n \"callout_fee\",\n \"specialties\",\n \"notes\",\n \"portal_token\",\n \"compliance_docs\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"address\",\n \"category\",\n \"account_number\",\n \"payment_terms\",\n \"notes\",\n \"attachments\",\n \"created_at\",\n \"updated_at\"\n ],\n leads: [\n \"id\",\n \"company_id\",\n \"number\",\n \"title\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"status\",\n \"source\",\n \"value\",\n \"description\",\n \"priority\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"created_at\",\n \"updated_at\"\n ],\n password_reset_requests: [\n \"id\",\n \"technician_id\",\n \"employee_id\",\n \"requested_at\",\n \"status\",\n \"token\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"type\",\n \"description\",\n \"priority\",\n \"read\",\n \"number\",\n \"asset_id\",\n \"job_id\",\n \"due_date\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g><text x=\"90\" y=\"36\" fill=\"#2D3134\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"25\" font-weight=\"900\" letter-spacing=\"0.04em\">DISPATCH</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nconst MODULE_PERMS = {\n 'Dashboard': [{ key: 'view' }],\n 'Customers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_contacts' }],\n 'Leads': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'convert' }],\n 'Quotes': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'approve' }, { key: 'convert' }, { key: 'generate_pdf' }],\n 'Jobs': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_tasks' }, { key: 'book_time' }, { key: 'view_costs' }, { key: 'view_quotes_tab' }, { key: 'view_pos_tab' }, { key: 'view_timesheets_tab' }, { key: 'view_invoices_tab' }, { key: 'manage_materials' }, { key: 'create_invoice' }],\n 'Timesheets': [{ key: 'view_own' }, { key: 'view' }, { key: 'create' }, { key: 'approve' }, { key: 'edit_all' }, { key: 'export' }],\n 'Assets': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Schedule': [{ key: 'view_own' }, { key: 'view' }, { key: 'edit' }],\n 'Contractors': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }],\n 'Suppliers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Stock': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Purchase Orders': [{ key: 'view' }, { key: 'create' }, { key: 'approve' }],\n 'Invoices': [{ key: 'view' }, { key: 'create' }, { key: 'send' }, { key: 'void' }],\n 'Reports': [{ key: 'view' }, { key: 'export' }],\n 'Documents': [{ key: 'view' }, { key: 'upload' }],\n 'Settings': [{ key: 'view' }, { key: 'edit_company' }, { key: 'manage_users' }, { key: 'manage_tax' }]\n};\n\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n this.db = null;\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n this.backupDirHandle = null;\n this.backupDirPermissionGranted = false;\n\n // Check if we have a cloud user at boot (before filling cache)\n const bootUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n const isCloudUser = !!(bootUser && bootUser.companyId && !bootUser.companyId.startsWith('acct_'));\n\n if (!isCloudUser && bootUser && bootUser.companyId && typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', bootUser.companyId);\n }\n\n // Pre-initialize empty arrays for collections.\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Load local settings if they exist (only for offline/demo mode)\n if (!isCloudUser && typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId && !this.companyId.startsWith('acct_')) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n // Only clear sync if we were previously in cloud sync mode or companyId is not defined\n if (!this.companyId || !this.companyId.startsWith('acct_')) {\n this.clearSync();\n }\n }\n });\n\n // Auto-trigger sync or local load if user already logged in at boot\n if (isCloudUser) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n this.initPromise = this.initializeCloudSync();\n } else {\n if (bootUser && bootUser.companyId) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n }\n this.initPromise = this.initializeLocalStore();\n }\n }\n\n getStorageKey(collection) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (activeAccount) {\n return `relay_${activeAccount}_${collection}`;\n }\n return `simpro_${collection}`;\n }\n\n getDBName() {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n return activeAccount ? `RelayDispatchDB_${activeAccount}` : 'RelayDispatchDB';\n }\n\n async initializeUser(user) {\n if (user && user.companyId && !user.companyId.startsWith('acct_')) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n } else {\n this.clearSync();\n if (user && user.companyId) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', user.companyId);\n }\n } else {\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n }\n this.initPromise = this.initializeLocalStore();\n await this.initPromise;\n }\n }\n\n async initializeLocalStore() {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n\n try {\n await this.initIndexedDB();\n } catch (e) {\n console.error('Failed to initialize IndexedDB, falling back to empty arrays:', e);\n }\n\n // Load local settings if they exist (namespaced)\n this.companySettings = null;\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n this.emit('settings', this.getSettings());\n\n // Seed default user types and forms if empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n if (this.cache.technicians.length === 0) {\n await this.seedDefaultTechnicians();\n }\n }\n\n initIndexedDB() {\n return new Promise((resolve, reject) => {\n if (typeof window === 'undefined' || !window.indexedDB) {\n return resolve();\n }\n\n const dbName = this.getDBName();\n const request = window.indexedDB.open(dbName, 3);\n\n request.onerror = (e) => {\n console.error('IndexedDB open error:', e.target.error);\n reject(e.target.error);\n };\n\n request.onsuccess = async (e) => {\n this.db = e.target.result;\n \n // 1. Load custom directory sync config from IndexedDB config store if it exists\n try {\n if (this.db.objectStoreNames.contains('config')) {\n await new Promise((resConfig) => {\n const transaction = this.db.transaction('config', 'readonly');\n const configStore = transaction.objectStore('config');\n \n const getSync = new Promise((resolve) => {\n const req = configStore.get('directory_handle');\n req.onsuccess = () => {\n console.log('[DEBUG Store] getSync onsuccess result:', req.result);\n if (req.result) {\n this.dirHandle = req.result.value;\n console.log('[DEBUG Store] loaded dirHandle:', this.dirHandle ? this.dirHandle.name : null);\n }\n resolve();\n };\n req.onerror = (e) => {\n console.error('[DEBUG Store] getSync onerror:', e);\n resolve();\n };\n });\n\n const getBackup = new Promise((resolve) => {\n const req = configStore.get('backup_directory_handle');\n req.onsuccess = () => {\n if (req.result) this.backupDirHandle = req.result.value;\n resolve();\n };\n req.onerror = () => resolve();\n });\n\n Promise.all([getSync, getBackup]).then(() => resConfig());\n });\n }\n } catch (err) {\n console.warn('Failed to read config from IndexedDB:', err);\n }\n\n // Verify directory permission silently on boot if handle exists\n if (this.dirHandle) {\n try {\n // Check permission silently without prompt dialog\n const opts = { mode: 'readwrite' };\n const status = await this.dirHandle.queryPermission(opts);\n this.folderSyncPermissionGranted = status === 'granted';\n } catch (err) {\n console.warn('Failed to query directory permission on boot:', err);\n }\n }\n\n // Verify backup directory permission silently on boot if handle exists\n if (this.backupDirHandle) {\n try {\n const opts = { mode: 'readwrite' };\n const status = await this.backupDirHandle.queryPermission(opts);\n this.backupDirPermissionGranted = status === 'granted';\n\n // Auto-trigger backup if granted and > 7 days since last backup\n if (this.backupDirPermissionGranted) {\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;\n if (!lastBackup || new Date(lastBackup).getTime() < sevenDaysAgo) {\n this.backupToFolder().catch(err => {\n console.error('Auto background backup failed:', err);\n });\n }\n }\n } catch (err) {\n console.warn('Failed to query backup directory permission on boot:', err);\n }\n }\n\n // Load all collections into cache\n const collections = Object.keys(TABLE_MAP);\n const loadPromises = collections.map(async (col) => {\n try {\n this.cache[col] = await this.readAllFromIndexedDB(col);\n } catch (err) {\n console.error(`Error loading collection ${col} from IndexedDB:`, err);\n this.cache[col] = [];\n }\n });\n\n await Promise.all(loadPromises);\n\n // Perform migration if we have old data in localStorage (only if not in a namespaced local account profile)\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (!activeAccount) {\n const migratePromises = collections.map(async (col) => {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n const items = JSON.parse(localData);\n if (Array.isArray(items) && items.length > 0) {\n // Write all items to IndexedDB\n await this.writeAllToIndexedDB(col, items);\n // Merge into cache if cache is empty\n if (this.cache[col].length === 0) {\n this.cache[col] = items;\n }\n }\n localStorage.removeItem('simpro_' + col);\n } catch (err) {\n console.error(`Migration error for ${col}:`, err);\n }\n }\n });\n\n await Promise.all(migratePromises);\n }\n resolve();\n };\n\n request.onupgradeneeded = (e) => {\n const db = e.target.result;\n const collections = Object.keys(TABLE_MAP);\n collections.forEach(col => {\n if (!db.objectStoreNames.contains(col)) {\n db.createObjectStore(col, { keyPath: 'id' });\n }\n });\n if (!db.objectStoreNames.contains('config')) {\n db.createObjectStore('config', { keyPath: 'key' });\n }\n };\n });\n }\n\n readAllFromIndexedDB(storeName) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve([]);\n try {\n const transaction = this.db.transaction(storeName, 'readonly');\n const store = transaction.objectStore(storeName);\n const getAllRequest = store.getAll();\n\n getAllRequest.onsuccess = () => {\n resolve(getAllRequest.result || []);\n };\n\n getAllRequest.onerror = (e) => {\n reject(e.target.error);\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeRecordToIndexedDB(storeName, record) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const putRequest = store.put(record);\n\n putRequest.onsuccess = () => resolve();\n putRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n deleteRecordFromIndexedDB(storeName, id) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const deleteRequest = store.delete(id);\n\n deleteRequest.onsuccess = () => resolve();\n deleteRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeAllToIndexedDB(storeName, items) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n \n // Clear existing\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n \n clearRequest.onsuccess = () => {\n let count = 0;\n if (items.length === 0) return resolve();\n \n items.forEach(item => {\n const putRequest = store.put(item);\n putRequest.onerror = (e) => reject(e.target.error);\n putRequest.onsuccess = () => {\n count++;\n if (count === items.length) {\n resolve();\n }\n };\n });\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n clearAllIndexedDB() {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const collections = Object.keys(TABLE_MAP);\n const transaction = this.db.transaction(collections, 'readwrite');\n let count = 0;\n \n collections.forEach(col => {\n const store = transaction.objectStore(col);\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n clearRequest.onsuccess = () => {\n count++;\n if (count === collections.length) {\n resolve();\n }\n };\n });\n } catch (err) {\n reject(err);\n }\n });\n }\n\n // ── Local Folder Sync / File System Access Driver ──────────────────────────\n\n async verifyDirPermission(readWrite) {\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n this.folderSyncPermissionGranted = true;\n return true; // Capacitor native has permissions checked at app level\n }\n\n if (!this.dirHandle) return false;\n const opts = {};\n if (readWrite) opts.mode = 'readwrite';\n \n try {\n const currentPerm = await this.dirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.folderSyncPermissionGranted = true;\n return true;\n }\n \n // Request permission (must be called within a user gesture handler)\n const newPerm = await this.dirHandle.requestPermission(opts);\n this.folderSyncPermissionGranted = newPerm === 'granted';\n return this.folderSyncPermissionGranted;\n } catch (e) {\n this.folderSyncPermissionGranted = false;\n return false;\n }\n }\n\n async verifyBackupDirPermission(requestInteractive) {\n if (!this.backupDirHandle) return false;\n const opts = { mode: 'readwrite' };\n try {\n const currentPerm = await this.backupDirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.backupDirPermissionGranted = true;\n return true;\n }\n if (requestInteractive) {\n const newPerm = await this.backupDirHandle.requestPermission(opts);\n this.backupDirPermissionGranted = newPerm === 'granted';\n return this.backupDirPermissionGranted;\n }\n this.backupDirPermissionGranted = false;\n return false;\n } catch (e) {\n this.backupDirPermissionGranted = false;\n return false;\n }\n }\n\n // Ensure a subfolder for the current company exists within the selected directory.\n async ensureCompanyFolderHandle(customDirHandle) {\n const baseHandle = customDirHandle || this.dirHandle;\n if (!baseHandle) return null;\n const rawName = this.companySettings?.name || this.companySettings?.id || 'Company';\n const safeName = rawName.replace(/[\\\\/:*?\"<>|]/g, '_');\n try {\n const companyHandle = await baseHandle.getDirectoryHandle(safeName, { create: true });\n return companyHandle;\n } catch (e) {\n console.error('Failed to get/create company folder:', e);\n return null;\n }\n }\n\n async setBackupDirectory(dirHandle) {\n this.backupDirHandle = dirHandle || null;\n this.backupDirPermissionGranted = !!dirHandle;\n \n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n if (dirHandle) {\n const putReq = configStore.put({ key: 'backup_directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = () => resolve();\n } else {\n const deleteReq = configStore.delete('backup_directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n }\n });\n }\n }\n\n async backupToFolder(dirHandle) {\n const handle = dirHandle || this.backupDirHandle;\n if (!handle) throw new Error('No backup directory configured.');\n \n const currentPerm = await handle.queryPermission({ mode: 'readwrite' });\n if (currentPerm !== 'granted') {\n const newPerm = await handle.requestPermission({ mode: 'readwrite' });\n if (newPerm !== 'granted') {\n throw new Error('Write permission denied for backup directory.');\n }\n }\n \n // Ensure company-specific folder exists within the selected directory\n const companyFolderHandle = await this.ensureCompanyFolderHandle(dirHandle);\n const dataDir = await companyFolderHandle.getDirectoryHandle('data', { create: true });\n const collections = Object.keys(TABLE_MAP);\n for (const col of collections) {\n const items = this.cache[col] || [];\n const fileHandle = await dataDir.getFileHandle(`${col}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items, null, 2));\n await writable.close();\n }\n \n // Update last backup timestamp\n localStorage.setItem('relay_last_backup_time', new Date().toISOString());\n this.backupDirPermissionGranted = true;\n }\n\n async setLocalDirectory(dirHandle) {\n this.dirHandle = dirHandle;\n if (dirHandle) {\n this.folderSyncEnabled = true;\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('folder_sync_enabled'), 'true');\n }\n \n // Save handle to IndexedDB\n if (this.db) {\n await new Promise((resolve, reject) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const putReq = configStore.put({ key: 'directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = (e) => reject(e.target.error);\n });\n }\n \n this.folderSyncPermissionGranted = true;\n \n // Force rewrite all cached collections to directory immediately\n const collections = Object.keys(TABLE_MAP);\n await Promise.all(collections.map(col => this.writeCollectionToFolder(col, this.cache[col])));\n } else {\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n \n // Remove handle from IndexedDB\n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const deleteReq = configStore.delete('directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n });\n }\n }\n }\n\n async writeCollectionToFolder(collection, items) {\n if (!this.folderSyncEnabled) return;\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/data/${collection}.json`,\n data: JSON.stringify(items || [], null, 2),\n directory: 'DOCUMENTS',\n encoding: 'utf8',\n recursive: true\n });\n } catch (err) {\n console.error(`Capacitor failed to write collection ${collection}:`, err);\n }\n return;\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return;\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return;\n const dataDir = await companyFolder.getDirectoryHandle('data', { create: true });\n const fileHandle = await dataDir.getFileHandle(`${collection}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items || [], null, 2));\n await writable.close();\n } catch (err) {\n console.error(`Failed to write collection ${collection} to local directory:`, err);\n }\n }\n\n async writeDocumentFileToFolder(docId, name, dataUrl) {\n if (!this.folderSyncEnabled) return null;\n\n const ext = name.includes('.') ? name.split('.').pop() : 'bin';\n const fileName = `${docId}.${ext}`;\n const relativePath = `documents/${fileName}`;\n\n // Extract base64 payload\n const base64Parts = dataUrl.split(',');\n if (base64Parts.length < 2) return null;\n const base64Data = base64Parts[1];\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/documents/${fileName}`,\n data: base64Data,\n directory: 'DOCUMENTS',\n recursive: true\n });\n return relativePath;\n } catch (err) {\n console.error('Capacitor failed to write document:', err);\n return null;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return null;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return null;\n\n const mimeMatch = base64Parts[0].match(/:(.*?);/);\n const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';\n const binaryStr = atob(base64Data);\n const len = binaryStr.length;\n const bytes = new Uint8Array(len);\n for (let i = 0; i < len; i++) {\n bytes[i] = binaryStr.charCodeAt(i);\n }\n const blob = new Blob([bytes], { type: mime });\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return null;\n const docsDir = await companyFolder.getDirectoryHandle('documents', { create: true });\n const fileHandle = await docsDir.getFileHandle(fileName, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(blob);\n await writable.close();\n\n return relativePath;\n } catch (err) {\n console.error('Failed to save document file to local directory:', err);\n return null;\n }\n }\n\n async getDocumentUrl(doc) {\n if (!doc || !doc.url) return '';\n if (doc.url.startsWith('data:') || doc.url.startsWith('http')) {\n return doc.url;\n }\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n const result = await Filesystem.readFile({\n path: `RelayDispatchData/${doc.url}`,\n directory: 'DOCUMENTS'\n });\n const mime = doc.type || 'application/octet-stream';\n return `data:${mime};base64,${result.data}`;\n } catch (err) {\n console.error('Capacitor failed to read document file:', err);\n throw err;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) {\n throw new Error('Local directory sync is not configured.');\n }\n\n try {\n const hasPerm = await this.verifyDirPermission(false);\n if (!hasPerm) {\n throw new Error('Permission to access the local directory was not granted.');\n }\n\n const parts = doc.url.split('/');\n const dirName = parts[0];\n const fileName = parts[1];\n\n const dir = await this.dirHandle.getDirectoryHandle(dirName);\n const fileHandle = await dir.getFileHandle(fileName);\n const file = await fileHandle.getFile();\n return URL.createObjectURL(file);\n } catch (err) {\n console.error('Failed to read file from local directory:', err);\n throw err;\n }\n }\n\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // Initialize IndexedDB for cloud users too, to store backup directory handles\n await this.initIndexedDB();\n\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id\n query = query.eq('company_id', this.companyId);\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n await this.writeAllToIndexedDB(col, this.cache[col]);\n }\n });\n\n await Promise.all(promises);\n\n // Seed default user types if database is empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n // Unique channel name per subscribe — reusing a fixed name throws\n // \"cannot add postgres_changes callbacks ... after subscribe()\" when sync re-inits.\n const channel = supabase\n .channel(`table-db-changes-${this.companyId || 'anon'}-${Date.now()}`)\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n if (collection === 'contractors') {\n record.businessName = record.name;\n record.active = record.status === 'Active';\n }\n\n if (collection === 'suppliers') {\n record.active = record.status === 'Active';\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.project_id !== undefined) {\n record.projectId = record.project_id;\n delete record.project_id;\n }\n if (record.cost_center_id !== undefined) {\n record.costCenterId = record.cost_center_id;\n delete record.cost_center_id;\n }\n if (record.xero_sales_account_code !== undefined) {\n record.xeroSalesAccountCode = record.xero_sales_account_code;\n delete record.xero_sales_account_code;\n }\n if (record.xero_expense_account_code !== undefined) {\n record.xeroExpenseAccountCode = record.xero_expense_account_code;\n delete record.xero_expense_account_code;\n }\n if (record.xero_tracking_category_name !== undefined) {\n record.xeroTrackingCategoryName = record.xero_tracking_category_name;\n delete record.xero_tracking_category_name;\n }\n if (record.xero_tracking_option_name !== undefined) {\n record.xeroTrackingOptionName = record.xero_tracking_option_name;\n delete record.xero_tracking_option_name;\n }\n if (record.deactivated_at !== undefined) {\n record.deactivatedAt = record.deactivated_at;\n delete record.deactivated_at;\n }\n if (record.start_date !== undefined) {\n record.startDate = record.start_date;\n delete record.start_date;\n }\n if (record.end_date !== undefined) {\n record.endDate = record.end_date;\n delete record.end_date;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.license_number !== undefined) { record.licenseNumber = record.license_number; delete record.license_number; }\n if (record.hourly_rate !== undefined) { record.hourlyRate = parseFloat(record.hourly_rate); delete record.hourly_rate; }\n if (record.after_hours_rate !== undefined) { record.afterHoursRate = parseFloat(record.after_hours_rate); delete record.after_hours_rate; }\n if (record.callout_fee !== undefined) { record.calloutFee = parseFloat(record.callout_fee); delete record.callout_fee; }\n if (record.compliance_docs !== undefined) { record.complianceDocs = record.compliance_docs; delete record.compliance_docs; }\n if (record.account_number !== undefined) { record.accountNumber = record.account_number; delete record.account_number; }\n if (record.payment_terms !== undefined) { record.paymentTerms = record.payment_terms; delete record.payment_terms; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.day_offset !== undefined) { record.dayOffset = parseInt(record.day_offset); delete record.day_offset; }\n if (record.start_hour !== undefined) { record.startHour = parseFloat(record.start_hour); delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = parseFloat(record.end_hour); delete record.end_hour; }\n\n if (record.color && record.color.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.color.substring(9));\n record.taskId = parsed.taskId;\n record.taskPath = parsed.taskId;\n record.taskName = parsed.taskName;\n record.color = parsed.color;\n if (parsed.date !== undefined) record.date = parsed.date;\n if (parsed.startTime !== undefined) record.startTime = parsed.startTime;\n if (parsed.finishTime !== undefined) record.finishTime = parsed.finishTime;\n if (parsed.hours !== undefined) record.hours = parsed.hours;\n if (parsed.startHour !== undefined && parsed.startHour !== null) record.startHour = parsed.startHour;\n if (parsed.endHour !== undefined && parsed.endHour !== null) record.endHour = parsed.endHour;\n if (parsed.dayOffset !== undefined && parsed.dayOffset !== null) record.dayOffset = parsed.dayOffset;\n } catch (e) {\n console.error(e);\n }\n }\n if (collection === 'invoices') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.invoiceType = 'Standard';\n record.laborProfileId = '';\n record.issueDate = '';\n record.originalQuoteId = '';\n record.originalQuoteNumber = '';\n record.originalSubtotal = 0;\n record.approvedVariationsSum = 0;\n record.pendingVariationsSum = 0;\n record.totalInternalCost = 0;\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.invoiceType = meta.invoiceType || 'Standard';\n record.laborProfileId = meta.laborProfileId || '';\n record.issueDate = meta.issueDate || '';\n record.originalQuoteId = meta.originalQuoteId || '';\n record.originalQuoteNumber = meta.originalQuoteNumber || '';\n record.originalSubtotal = meta.originalSubtotal || 0;\n record.approvedVariationsSum = meta.approvedVariationsSum || 0;\n record.pendingVariationsSum = meta.pendingVariationsSum || 0;\n record.totalInternalCost = meta.totalInternalCost || 0;\n }\n }\n if (collection === 'quotes') {\n let meta = record.lineItems || record.line_items;\n if (typeof meta === 'string') {\n try { meta = JSON.parse(meta); } catch (e) {}\n }\n if (Array.isArray(meta)) {\n record.sections = [{ id: this.generateId(), name: 'Main Phase', lineItems: meta }];\n record.laborProfileId = '';\n record.description = '';\n record.isTemplate = false;\n record.templateId = '';\n } else if (meta && typeof meta === 'object') {\n record.sections = meta.sections || [];\n record.laborProfileId = meta.laborProfileId || '';\n record.description = meta.description || '';\n record.isTemplate = meta.isTemplate || false;\n record.templateId = meta.templateId || '';\n }\n }\n if (collection === 'jobs') {\n if (record.notes && record.notes.startsWith('__meta__:')) {\n try {\n const meta = JSON.parse(record.notes.substring(9));\n record.isRecurring = meta.isRecurring || false;\n record.recurringConfig = meta.recurringConfig || null;\n record.preferredTime = meta.preferredTime || '';\n record.description = meta.description || '';\n record.parentJobId = meta.parentJobId || null;\n record.notes = meta.notes || '';\n } catch (e) {\n console.error('Error parsing jobs meta:', e);\n }\n }\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (collection === 'contractors') {\n record.name = record.businessName;\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (collection === 'suppliers') {\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.xeroSalesAccountCode !== undefined) {\n record.xero_sales_account_code = record.xeroSalesAccountCode;\n delete record.xeroSalesAccountCode;\n }\n if (record.xeroExpenseAccountCode !== undefined) {\n record.xero_expense_account_code = record.xeroExpenseAccountCode;\n delete record.xeroExpenseAccountCode;\n }\n if (record.xeroTrackingCategoryName !== undefined) {\n record.xero_tracking_category_name = record.xeroTrackingCategoryName;\n delete record.xeroTrackingCategoryName;\n }\n if (record.xeroTrackingOptionName !== undefined) {\n record.xero_tracking_option_name = record.xeroTrackingOptionName;\n delete record.xeroTrackingOptionName;\n }\n if (record.deactivatedAt !== undefined) {\n record.deactivated_at = record.deactivatedAt;\n delete record.deactivatedAt;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.projectId !== undefined) {\n record.project_id = record.projectId;\n delete record.projectId;\n }\n if (record.costCenterId !== undefined) {\n record.cost_center_id = record.costCenterId;\n delete record.costCenterId;\n }\n if (record.startDate !== undefined) {\n record.start_date = record.startDate;\n delete record.startDate;\n }\n if (record.endDate !== undefined) {\n record.end_date = record.endDate;\n delete record.endDate;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.licenseNumber !== undefined) { record.license_number = record.licenseNumber; delete record.licenseNumber; }\n if (record.hourlyRate !== undefined) { record.hourly_rate = record.hourlyRate; delete record.hourlyRate; }\n if (record.afterHoursRate !== undefined) { record.after_hours_rate = record.afterHoursRate; delete record.afterHoursRate; }\n if (record.calloutFee !== undefined) { record.callout_fee = record.calloutFee; delete record.calloutFee; }\n if (record.complianceDocs !== undefined) { record.compliance_docs = record.complianceDocs; delete record.complianceDocs; }\n if (record.accountNumber !== undefined) { record.account_number = record.accountNumber; delete record.accountNumber; }\n if (record.paymentTerms !== undefined) { record.payment_terms = record.paymentTerms; delete record.paymentTerms; }\n if (record.jobNumber !== undefined) { record.job_number = record.jobNumber; delete record.jobNumber; }\n if (record.dayOffset !== undefined) { record.day_offset = record.dayOffset; delete record.dayOffset; }\n if (record.startHour !== undefined) { record.start_hour = record.startHour; delete record.startHour; }\n if (record.endHour !== undefined) { record.end_hour = record.endHour; delete record.endHour; }\n\n if (collection === 'schedule') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || null,\n color: record.color || null,\n date: record.date || null,\n startTime: record.startTime || null,\n finishTime: record.finishTime || null,\n hours: record.hours || null,\n startHour: record.startHour !== undefined ? record.startHour : null,\n endHour: record.endHour !== undefined ? record.endHour : null,\n dayOffset: record.dayOffset !== undefined ? record.dayOffset : null\n };\n record.color = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'jobs') {\n const meta = {\n isRecurring: record.isRecurring || false,\n recurringConfig: record.recurringConfig || null,\n preferredTime: record.preferredTime || '',\n description: record.description || '',\n parentJobId: record.parentJobId || null,\n notes: record.notes || ''\n };\n record.notes = '__meta__:' + JSON.stringify(meta);\n }\n\n if (collection === 'invoices') {\n const meta = {\n sections: record.sections || [],\n invoiceType: record.invoiceType || 'Standard',\n laborProfileId: record.laborProfileId || '',\n issueDate: record.issueDate || '',\n originalQuoteId: record.originalQuoteId || '',\n originalQuoteNumber: record.originalQuoteNumber || '',\n originalSubtotal: record.originalSubtotal || 0,\n approvedVariationsSum: record.approvedVariationsSum || 0,\n pendingVariationsSum: record.pendingVariationsSum || 0,\n totalInternalCost: record.totalInternalCost || 0\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n if (collection === 'quotes') {\n const meta = {\n sections: record.sections || [],\n laborProfileId: record.laborProfileId || '',\n description: record.description || '',\n isTemplate: record.isTemplate || false,\n templateId: record.templateId || ''\n };\n record.line_items = meta;\n delete record.lineItems;\n }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n const items = this.cache[collection] || [];\n if (collection === 'technicians') {\n const loginMode = typeof localStorage !== 'undefined' ? localStorage.getItem('relay_login_mode') : null;\n if (loginMode === 'local') {\n const currentUserStr = typeof localStorage !== 'undefined' ? localStorage.getItem('currentUser') : null;\n if (currentUserStr) {\n const currentUser = JSON.parse(currentUserStr);\n if (currentUser && currentUser.id) {\n return [{\n id: currentUser.id,\n name: currentUser.name || 'Local Admin',\n role: 'Administrator',\n color: currentUser.color || '#FF5C00',\n userTypeId: currentUser.userTypeId,\n email: currentUser.email || '',\n username: currentUser.username || ''\n }];\n }\n }\n }\n }\n return items;\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Surface a failed background write to the user instead of failing silently.\n // (A silent insert/update failure leaves the optimistic cache entry in place, so the\n // record looks saved until the next reload — the classic \"it didn't save\" symptom.)\n _notifyWriteError(action, collection, error) {\n console.error(`Error trying to ${action} ${collection}:`, error?.message || error, error);\n try {\n import('../components/Notifications.js')\n .then(({ showToast }) => showToast(\n `Couldn't ${action} ${collection.replace(/s$/, '')} — ${error?.message || 'database error'}`,\n 'error'))\n .catch(() => {});\n } catch (e) {}\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = this.getNextNumber('LD-', 'leads');\n }\n if (collection === 'notifications' && !item.number) {\n item.number = this.getNextNumber('NT-', 'notifications');\n }\n if (collection === 'invoices' && !item.number) {\n item.number = this.getNextNumber('INV-', 'invoices');\n }\n if (collection === 'quotes' && !item.number) {\n item.number = this.getNextNumber('Q-', 'quotes');\n }\n if (collection === 'jobs' && !item.number) {\n item.number = this.getNextNumber('J-', 'jobs');\n }\n if (collection === 'purchaseOrders' && !item.number) {\n item.number = this.getNextNumber('PO-', 'purchaseOrders');\n }\n if (collection === 'projects' && !item.number) {\n item.number = this.getNextNumber('PRJ-', 'projects');\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Local Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (this.folderSyncEnabled) {\n if (collection === 'documents' && item.url && item.url.startsWith('data:')) {\n this.writeDocumentFileToFolder(item.id, item.name, item.url).then(relPath => {\n if (relPath) {\n item.url = relPath;\n // Re-save in IndexedDB and rewrite folder collection with updated path\n this.writeRecordToIndexedDB(collection, item);\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n });\n } else {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n }\n this.writeRecordToIndexedDB(collection, item).catch(err => {\n console.error(`Error saving ${collection} to IndexedDB:`, err);\n });\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n item.email = email;\n\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) {\n // Roll back the optimistic add so the UI matches the database\n this.cache[collection] = (this.cache[collection] || []).filter(x => x.id !== item.id);\n this.emit(collection, this.cache[collection]);\n this._notifyWriteError('save', collection, error);\n }\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n this.writeRecordToIndexedDB(collection, updated).catch(err => {\n console.error(`Error saving updated ${collection} to IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const isAuthUpdate = updates.username !== undefined ||\n updates.email !== undefined ||\n updates.password !== undefined ||\n updates.name !== undefined ||\n updates.role !== undefined ||\n updates.userTypeId !== undefined ||\n updates.color !== undefined ||\n updates.payRate !== undefined;\n\n if (isAuthUpdate) {\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to update user.');\n }\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n const dbPayload = this.denormalizeRecord(updated, 'technicians');\n const { error: dbError } = await supabase\n .from('profiles')\n .update(dbPayload)\n .eq('id', id);\n\n if (dbError) {\n throw dbError;\n }\n\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Roll back to the previous version so the UI matches the database\n const arr = [...(this.cache[collection] || [])];\n const i = arr.findIndex(x => x.id === id);\n if (i !== -1) { arr[i] = previous; this.cache[collection] = arr; this.emit(collection, arr); }\n this._notifyWriteError('update', collection, error);\n }\n });\n }\n\n return updated;\n }\n\n // Child records that must not outlive their parent. Deleting a parent cascades to\n // these (scheduling/alert artifacts). Financial/labor records (invoices, timesheets)\n // are deliberately KEPT — they're history and carry their own denormalised numbers.\n _cascadeDelete(collection, id) {\n const CASCADES = {\n jobs: [ ['schedule', 'jobId'], ['formInstances', 'jobId'], ['notifications', 'jobId'] ],\n assets: [ ['maintenancePlans', 'assetId'], ['notifications', 'assetId'] ],\n formTemplates: [ ['formInstances', 'templateId'] ],\n };\n (CASCADES[collection] || []).forEach(([childCol, fk]) => {\n (this.cache[childCol] || []).filter(c => c[fk] === id).forEach(c => this.delete(childCol, c.id));\n });\n }\n\n delete(collection, id) {\n // 0. Cascade-remove dependent child records BEFORE the parent leaves the cache\n this._cascadeDelete(collection, id);\n\n // 1. Update memory cache (keep a copy of the removed row for rollback)\n const removed = (this.cache[collection] || []).find(item => item.id === id);\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n this.deleteRecordFromIndexedDB(collection, id).catch(err => {\n console.error(`Error deleting ${id} from ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Restore the row we optimistically removed\n if (removed) { this.cache[collection] = [...(this.cache[collection] || []), removed]; this.emit(collection, this.cache[collection]); }\n this._notifyWriteError('delete', collection, error);\n }\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n /**\n * Generate the next sequential number for a collection.\n * Scans existing records, finds the highest numeric suffix, and returns prefix + (max+1) zero-padded to 5 digits.\n * e.g. getNextNumber('INV-', 'invoices') → 'INV-00001', 'INV-00002', ...\n */\n getNextNumber(defaultPrefix, collection) {\n const settings = this.getSettings();\n const dt = settings.documentTheme || {};\n \n let prefix = defaultPrefix;\n let startingNum = 1;\n\n if (collection === 'invoices') {\n prefix = dt.invoicePrefix !== undefined ? dt.invoicePrefix : 'INV-';\n startingNum = dt.invoiceStartingNumber !== undefined ? parseInt(dt.invoiceStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n } else if (collection === 'quotes') {\n prefix = dt.quotePrefix !== undefined ? dt.quotePrefix : 'Q-';\n startingNum = dt.quoteStartingNumber !== undefined ? parseInt(dt.quoteStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n }\n\n const items = this.getAll(collection) || [];\n let maxNum = startingNum - 1;\n\n items.forEach(item => {\n if (item.number && typeof item.number === 'string' && item.number.startsWith(prefix)) {\n const numStr = item.number.slice(prefix.length);\n const num = parseInt(numStr, 10);\n if (!isNaN(num) && num > maxNum) {\n maxNum = num;\n }\n }\n });\n\n return prefix + (maxNum + 1).toString().padStart(5, '0');\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: defaultLogoLarge,\n logoSmall: defaultLogoSmall,\n markupPercent: 20,\n taxEnabled: true,\n taxRate: 10,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n jobTypes: ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'],\n supplierCategories: ['Electrical', 'Plumbing', 'HVAC', 'Fire Safety', 'Security', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n },\n ai: {\n enabled: (this.companyId && !this.companyId.startsWith('acct_')) ? !!(typeof import.meta.env !== 'undefined' && (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY)) : false,\n apiKey: (this.companyId && !this.companyId.startsWith('acct_')) ? (typeof import.meta.env !== 'undefined' ? (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY || '') : '') : '',\n endpoint: 'https://api.openai.com/v1/chat/completions',\n model: 'gpt-4o-mini',\n systemPrompt: 'You are Relay, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default logos if user has not uploaded custom ones\n if (!merged.logo || merged.logo.includes('logo-large.png')) merged.logo = defaultLogoLarge;\n if (!merged.logoSmall) merged.logoSmall = defaultLogoSmall;\n if (!merged.jobTypes) merged.jobTypes = [...defaultSettings.jobTypes];\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n merged.ai = { ...defaultSettings.ai, ...this.companySettings.ai };\n return merged;\n }\n\n return defaultSettings;\n }\n\n getTaxRate() {\n const settings = this.getSettings();\n if (settings.taxEnabled === false) return 0;\n const rate = settings.taxRate !== undefined ? Number(settings.taxRate) : 10;\n return rate / 100;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('settings'), JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId && !this.companyId.startsWith('acct_')) return true;\n return localStorage.getItem(this.getStorageKey('seeded')) === 'true';\n }\n\n save(collection, items) {\n const prev = this.cache[collection] || [];\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const p = this.writeAllToIndexedDB(collection, items).catch(err => {\n console.error(`Error saving all items to ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, items);\n }\n return p;\n }\n\n // Cloud mode: persist the whole collection. Upsert every current row, then delete\n // any rows that were removed from the array. (Previously this only touched the\n // in-memory cache, so every save() — assign-tech, role edits, form templates,\n // portal edits, stock receiving, mark-read, etc. — silently failed to persist.)\n const table = TABLE_MAP[collection];\n if (!table) return; // cache-only collections (e.g. 'activity') have no table\n\n const payload = items\n .filter(it => it && it.id)\n .map(it => this.denormalizeRecord({ ...it, companyId: this.companyId }, collection));\n if (payload.length) {\n supabase.from(table).upsert(payload).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n\n const currentIds = new Set(items.map(it => it && it.id));\n const removedIds = prev.filter(p => p && p.id && !currentIds.has(p.id)).map(p => p.id);\n if (removedIds.length) {\n supabase.from(table).delete().in('id', removedIds).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n }\n\n markSeeded() {\n localStorage.setItem(this.getStorageKey('seeded'), 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('formTemplates', templates);\n return;\n }\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n async seedDefaultUserTypes() {\n if (!this.companyId) return;\n\n const userTypes = [\n {\n id: `${this.companyId}_ut_admin`,\n name: 'Admin',\n description: 'Full system access',\n permissions: buildGranularPerms(() => true)\n },\n {\n id: `${this.companyId}_ut_manager`,\n name: 'Manager',\n description: 'Can manage most workflows but limited settings access',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company', 'manage_tax'].includes(key);\n return true;\n })\n },\n {\n id: `${this.companyId}_ut_tech`,\n name: 'Technician',\n description: 'Field staff — limited to their own jobs, schedule and timesheets',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n {\n id: `${this.companyId}_ut_office`,\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders', 'Suppliers'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n\n this.cache.userTypes = userTypes;\n this.emit('userTypes', userTypes);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('userTypes', userTypes);\n return;\n }\n\n const dbPayload = userTypes.map(ut => this.denormalizeRecord({ ...ut, companyId: this.companyId }, 'userTypes'));\n const { error } = await supabase.from('user_types').insert(dbPayload);\n if (error) console.error('Error seeding default user types:', error);\n }\n\n async seedDefaultTechnicians() {\n const companyId = this.companyId;\n if (!companyId) return;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const managerTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_manager` : 'ut_manager';\n const techTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_tech` : 'ut_tech';\n const officeTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_office` : 'ut_office';\n\n const defaultTechs = [\n { id: `${companyId}_tech_1`, name: 'Jake Morrow', role: 'Senior Electrician', color: '#3B82F6', userTypeId: adminTypeId, payRate: 95.00, email: 'jake@apexpowerservices.com.au', phone: '0412 233 445', username: 'jake', password: '123456' },\n { id: `${companyId}_tech_2`, name: 'Ryan Holt', role: 'Service Manager', color: '#10B981', userTypeId: managerTypeId, payRate: 85.00, email: 'ryan@apexpowerservices.com.au', phone: '0423 344 556', username: 'ryan', password: '123456' },\n { id: `${companyId}_tech_3`, name: 'Sandra Okafor', role: 'Electrician', color: '#8B5CF6', userTypeId: techTypeId, payRate: 80.00, email: 'sandra@apexpowerservices.com.au', phone: '0434 455 667', username: 'sandra', password: '123456' },\n { id: `${companyId}_tech_4`, name: 'Dean Caruso', role: 'Office Administrator',color: '#F59E0B', userTypeId: officeTypeId, payRate: 50.00, email: 'dean@apexpowerservices.com.au', phone: '0445 566 778', username: 'dean', password: '123456' }\n ];\n\n this.cache.technicians = defaultTechs;\n this.emit('technicians', defaultTechs);\n\n if (companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('technicians', defaultTechs);\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder('technicians', defaultTechs).catch(err => {\n console.error('Error writing seeded technicians to local folder:', err);\n });\n }\n return;\n }\n\n const dbPayload = defaultTechs.map(t => this.denormalizeRecord({ ...t, companyId }, 'technicians'));\n const { error } = await supabase.from('technicians').insert(dbPayload);\n if (error) console.error('Error seeding default technicians:', error);\n }\n\n async migrateLocalToCloud(companyId, adminUserId) {\n this.companyId = companyId;\n this.userId = adminUserId;\n\n // 1. Update the remote company settings with local companySettings\n const currentSettings = this.getSettings();\n const { error: compErr } = await supabase.from('companies').update({\n abn: currentSettings.abn || '',\n phone: currentSettings.phone || '',\n domain: currentSettings.domain || '',\n email: currentSettings.email || '',\n address: currentSettings.address || '',\n settings: currentSettings\n }).eq('id', companyId);\n if (compErr) console.error('Error updating cloud company settings during migration:', compErr);\n\n // 2. Loop through all collections and push to Supabase\n // Skip 'companies' and 'technicians'\n const collectionsToMigrate = Object.keys(TABLE_MAP).filter(col => col !== 'companies' && col !== 'technicians');\n\n for (const col of collectionsToMigrate) {\n const items = this.cache[col] || [];\n if (items.length === 0) continue;\n\n const table = TABLE_MAP[col];\n if (!table) continue;\n\n // Assign the new cloud company_id and denormalize\n const payload = items.map(item => {\n const itemCopy = { ...item, companyId };\n return this.denormalizeRecord(itemCopy, col);\n });\n\n // Write to Supabase using upsert\n const { error } = await supabase.from(table).upsert(payload);\n if (error) {\n console.error(`Error migrating collection ${col}:`, error);\n throw new Error(`Failed to migrate ${col} data: ${error.message}`);\n }\n }\n\n // 3. Clear/close local DB connection and sync from Cloud\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n }\n\n deleteLocalAccountData(accountId) {\n return new Promise((resolve) => {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n if (typeof localStorage !== 'undefined') {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem(`relay_${accountId}_${col}`);\n });\n localStorage.removeItem(`relay_${accountId}_folder_sync_enabled`);\n localStorage.removeItem(`relay_${accountId}_seeded`);\n localStorage.removeItem(`relay_${accountId}_settings`);\n }\n if (typeof window !== 'undefined' && window.indexedDB) {\n const req = window.indexedDB.deleteDatabase(`RelayDispatchDB_${accountId}`);\n req.onsuccess = () => resolve();\n req.onerror = () => resolve();\n req.onblocked = () => {\n console.warn('Database deletion blocked, resolving anyway');\n resolve();\n };\n } else {\n resolve();\n }\n });\n }\n\n async clearAll() {\n this.clearSync();\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n \n if (activeAccount) {\n await this.deleteLocalAccountData(activeAccount);\n } else {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n this.dirHandle = null;\n try {\n await this.clearAllIndexedDB();\n } catch (err) {\n console.error('Error clearing IndexedDB:', err);\n }\n }\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "f06374f4c6efefe115a2a42206649ab9ebae87fc": {"t": 1785754188.325077, "n": 95377, "h": "f21b9e26072473b3", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\nconst defaultLogoLarge = new URL('../assets/RELAY_Dispatch_Logo.png', import.meta.url).href;\nconst defaultLogoSmall = new URL('../assets/logo-small.png', import.meta.url).href;\n\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n passwordResetRequests: 'password_reset_requests',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents',\n leads: 'leads',\n schedule: 'schedule',\n projects: 'projects',\n costCenters: 'cost_centers'\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"project_id\",\n \"cost_center_id\",\n \"created_at\",\n \"updated_at\"\n ],\n projects: [\n \"id\",\n \"company_id\",\n \"number\",\n \"name\",\n \"customer_id\",\n \"customer_name\",\n \"site_address\",\n \"status\",\n \"description\",\n \"start_date\",\n \"end_date\",\n \"created_at\",\n \"updated_at\"\n ],\n costCenters: [\n \"id\",\n \"company_id\",\n \"name\",\n \"code\",\n \"active\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"license_number\",\n \"hourly_rate\",\n \"after_hours_rate\",\n \"callout_fee\",\n \"specialties\",\n \"notes\",\n \"portal_token\",\n \"compliance_docs\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"address\",\n \"category\",\n \"account_number\",\n \"payment_terms\",\n \"notes\",\n \"attachments\",\n \"created_at\",\n \"updated_at\"\n ],\n leads: [\n \"id\",\n \"company_id\",\n \"number\",\n \"title\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"status\",\n \"source\",\n \"value\",\n \"description\",\n \"priority\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"created_at\",\n \"updated_at\"\n ],\n password_reset_requests: [\n \"id\",\n \"technician_id\",\n \"employee_id\",\n \"requested_at\",\n \"status\",\n \"token\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"type\",\n \"description\",\n \"priority\",\n \"read\",\n \"number\",\n \"asset_id\",\n \"job_id\",\n \"due_date\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g><text x=\"90\" y=\"36\" fill=\"#2D3134\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"25\" font-weight=\"900\" letter-spacing=\"0.04em\">DISPATCH</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nconst MODULE_PERMS = {\n 'Dashboard': [{ key: 'view' }],\n 'Customers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_contacts' }],\n 'Leads': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'convert' }],\n 'Quotes': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'approve' }, { key: 'convert' }, { key: 'generate_pdf' }],\n 'Jobs': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_tasks' }, { key: 'book_time' }, { key: 'view_costs' }, { key: 'view_quotes_tab' }, { key: 'view_pos_tab' }, { key: 'view_timesheets_tab' }, { key: 'view_invoices_tab' }, { key: 'manage_materials' }, { key: 'create_invoice' }],\n 'Timesheets': [{ key: 'view_own' }, { key: 'view' }, { key: 'create' }, { key: 'approve' }, { key: 'edit_all' }, { key: 'export' }],\n 'Assets': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Schedule': [{ key: 'view_own' }, { key: 'view' }, { key: 'edit' }],\n 'Contractors': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }],\n 'Suppliers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Stock': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Purchase Orders': [{ key: 'view' }, { key: 'create' }, { key: 'approve' }],\n 'Invoices': [{ key: 'view' }, { key: 'create' }, { key: 'send' }, { key: 'void' }],\n 'Reports': [{ key: 'view' }, { key: 'export' }],\n 'Documents': [{ key: 'view' }, { key: 'upload' }],\n 'Settings': [{ key: 'view' }, { key: 'edit_company' }, { key: 'manage_users' }, { key: 'manage_tax' }]\n};\n\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n this.db = null;\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n this.backupDirHandle = null;\n this.backupDirPermissionGranted = false;\n\n // Check if we have a cloud user at boot (before filling cache)\n const bootUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n const isCloudUser = !!(bootUser && bootUser.companyId && !bootUser.companyId.startsWith('acct_'));\n\n if (!isCloudUser && bootUser && bootUser.companyId && typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', bootUser.companyId);\n }\n\n // Pre-initialize empty arrays for collections.\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Load local settings if they exist (only for offline/demo mode)\n if (!isCloudUser && typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId && !this.companyId.startsWith('acct_')) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n // Only clear sync if we were previously in cloud sync mode or companyId is not defined\n if (!this.companyId || !this.companyId.startsWith('acct_')) {\n this.clearSync();\n }\n }\n });\n\n // Auto-trigger sync or local load if user already logged in at boot\n if (isCloudUser) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n this.initPromise = this.initializeCloudSync();\n } else {\n if (bootUser && bootUser.companyId) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n }\n this.initPromise = this.initializeLocalStore();\n }\n }\n\n getStorageKey(collection) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (activeAccount) {\n return `relay_${activeAccount}_${collection}`;\n }\n return `simpro_${collection}`;\n }\n\n getDBName() {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n return activeAccount ? `RelayDispatchDB_${activeAccount}` : 'RelayDispatchDB';\n }\n\n async initializeUser(user) {\n if (user && user.companyId && !user.companyId.startsWith('acct_')) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n } else {\n this.clearSync();\n if (user && user.companyId) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', user.companyId);\n }\n } else {\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n }\n this.initPromise = this.initializeLocalStore();\n await this.initPromise;\n }\n }\n\n async initializeLocalStore() {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n\n try {\n await this.initIndexedDB();\n } catch (e) {\n console.error('Failed to initialize IndexedDB, falling back to empty arrays:', e);\n }\n\n // Load local settings if they exist (namespaced)\n this.companySettings = null;\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n this.emit('settings', this.getSettings());\n\n // Seed default user types and forms if empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n if (this.cache.technicians.length === 0) {\n await this.seedDefaultTechnicians();\n }\n }\n\n initIndexedDB() {\n return new Promise((resolve, reject) => {\n if (typeof window === 'undefined' || !window.indexedDB) {\n return resolve();\n }\n\n const dbName = this.getDBName();\n const request = window.indexedDB.open(dbName, 3);\n\n request.onerror = (e) => {\n console.error('IndexedDB open error:', e.target.error);\n reject(e.target.error);\n };\n\n request.onsuccess = async (e) => {\n this.db = e.target.result;\n \n // 1. Load custom directory sync config from IndexedDB config store if it exists\n try {\n if (this.db.objectStoreNames.contains('config')) {\n await new Promise((resConfig) => {\n const transaction = this.db.transaction('config', 'readonly');\n const configStore = transaction.objectStore('config');\n \n const getSync = new Promise((resolve) => {\n const req = configStore.get('directory_handle');\n req.onsuccess = () => {\n console.log('[DEBUG Store] getSync onsuccess result:', req.result);\n if (req.result) {\n this.dirHandle = req.result.value;\n console.log('[DEBUG Store] loaded dirHandle:', this.dirHandle ? this.dirHandle.name : null);\n }\n resolve();\n };\n req.onerror = (e) => {\n console.error('[DEBUG Store] getSync onerror:', e);\n resolve();\n };\n });\n\n const getBackup = new Promise((resolve) => {\n const req = configStore.get('backup_directory_handle');\n req.onsuccess = () => {\n if (req.result) this.backupDirHandle = req.result.value;\n resolve();\n };\n req.onerror = () => resolve();\n });\n\n Promise.all([getSync, getBackup]).then(() => resConfig());\n });\n }\n } catch (err) {\n console.warn('Failed to read config from IndexedDB:', err);\n }\n\n // Verify directory permission silently on boot if handle exists\n if (this.dirHandle) {\n try {\n // Check permission silently without prompt dialog\n const opts = { mode: 'readwrite' };\n const status = await this.dirHandle.queryPermission(opts);\n this.folderSyncPermissionGranted = status === 'granted';\n } catch (err) {\n console.warn('Failed to query directory permission on boot:', err);\n }\n }\n\n // Verify backup directory permission silently on boot if handle exists\n if (this.backupDirHandle) {\n try {\n const opts = { mode: 'readwrite' };\n const status = await this.backupDirHandle.queryPermission(opts);\n this.backupDirPermissionGranted = status === 'granted';\n\n // Auto-trigger backup if granted and > 7 days since last backup\n if (this.backupDirPermissionGranted) {\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;\n if (!lastBackup || new Date(lastBackup).getTime() < sevenDaysAgo) {\n this.backupToFolder().catch(err => {\n console.error('Auto background backup failed:', err);\n });\n }\n }\n } catch (err) {\n console.warn('Failed to query backup directory permission on boot:', err);\n }\n }\n\n // Load all collections into cache\n const collections = Object.keys(TABLE_MAP);\n const loadPromises = collections.map(async (col) => {\n try {\n this.cache[col] = await this.readAllFromIndexedDB(col);\n } catch (err) {\n console.error(`Error loading collection ${col} from IndexedDB:`, err);\n this.cache[col] = [];\n }\n });\n\n await Promise.all(loadPromises);\n\n // Perform migration if we have old data in localStorage (only if not in a namespaced local account profile)\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (!activeAccount) {\n const migratePromises = collections.map(async (col) => {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n const items = JSON.parse(localData);\n if (Array.isArray(items) && items.length > 0) {\n // Write all items to IndexedDB\n await this.writeAllToIndexedDB(col, items);\n // Merge into cache if cache is empty\n if (this.cache[col].length === 0) {\n this.cache[col] = items;\n }\n }\n localStorage.removeItem('simpro_' + col);\n } catch (err) {\n console.error(`Migration error for ${col}:`, err);\n }\n }\n });\n\n await Promise.all(migratePromises);\n }\n resolve();\n };\n\n request.onupgradeneeded = (e) => {\n const db = e.target.result;\n const collections = Object.keys(TABLE_MAP);\n collections.forEach(col => {\n if (!db.objectStoreNames.contains(col)) {\n db.createObjectStore(col, { keyPath: 'id' });\n }\n });\n if (!db.objectStoreNames.contains('config')) {\n db.createObjectStore('config', { keyPath: 'key' });\n }\n };\n });\n }\n\n readAllFromIndexedDB(storeName) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve([]);\n try {\n const transaction = this.db.transaction(storeName, 'readonly');\n const store = transaction.objectStore(storeName);\n const getAllRequest = store.getAll();\n\n getAllRequest.onsuccess = () => {\n resolve(getAllRequest.result || []);\n };\n\n getAllRequest.onerror = (e) => {\n reject(e.target.error);\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeRecordToIndexedDB(storeName, record) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const putRequest = store.put(record);\n\n putRequest.onsuccess = () => resolve();\n putRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n deleteRecordFromIndexedDB(storeName, id) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const deleteRequest = store.delete(id);\n\n deleteRequest.onsuccess = () => resolve();\n deleteRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeAllToIndexedDB(storeName, items) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n \n // Clear existing\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n \n clearRequest.onsuccess = () => {\n let count = 0;\n if (items.length === 0) return resolve();\n \n items.forEach(item => {\n const putRequest = store.put(item);\n putRequest.onerror = (e) => reject(e.target.error);\n putRequest.onsuccess = () => {\n count++;\n if (count === items.length) {\n resolve();\n }\n };\n });\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n clearAllIndexedDB() {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const collections = Object.keys(TABLE_MAP);\n const transaction = this.db.transaction(collections, 'readwrite');\n let count = 0;\n \n collections.forEach(col => {\n const store = transaction.objectStore(col);\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n clearRequest.onsuccess = () => {\n count++;\n if (count === collections.length) {\n resolve();\n }\n };\n });\n } catch (err) {\n reject(err);\n }\n });\n }\n\n // ── Local Folder Sync / File System Access Driver ──────────────────────────\n\n async verifyDirPermission(readWrite) {\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n this.folderSyncPermissionGranted = true;\n return true; // Capacitor native has permissions checked at app level\n }\n\n if (!this.dirHandle) return false;\n const opts = {};\n if (readWrite) opts.mode = 'readwrite';\n \n try {\n const currentPerm = await this.dirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.folderSyncPermissionGranted = true;\n return true;\n }\n \n // Request permission (must be called within a user gesture handler)\n const newPerm = await this.dirHandle.requestPermission(opts);\n this.folderSyncPermissionGranted = newPerm === 'granted';\n return this.folderSyncPermissionGranted;\n } catch (e) {\n this.folderSyncPermissionGranted = false;\n return false;\n }\n }\n\n async verifyBackupDirPermission(requestInteractive) {\n if (!this.backupDirHandle) return false;\n const opts = { mode: 'readwrite' };\n try {\n const currentPerm = await this.backupDirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.backupDirPermissionGranted = true;\n return true;\n }\n if (requestInteractive) {\n const newPerm = await this.backupDirHandle.requestPermission(opts);\n this.backupDirPermissionGranted = newPerm === 'granted';\n return this.backupDirPermissionGranted;\n }\n this.backupDirPermissionGranted = false;\n return false;\n } catch (e) {\n this.backupDirPermissionGranted = false;\n return false;\n }\n }\n\n // Ensure a subfolder for the current company exists within the selected directory.\n async ensureCompanyFolderHandle(customDirHandle) {\n const baseHandle = customDirHandle || this.dirHandle;\n if (!baseHandle) return null;\n const rawName = this.companySettings?.name || this.companySettings?.id || 'Company';\n const safeName = rawName.replace(/[\\\\/:*?\"<>|]/g, '_');\n try {\n const companyHandle = await baseHandle.getDirectoryHandle(safeName, { create: true });\n return companyHandle;\n } catch (e) {\n console.error('Failed to get/create company folder:', e);\n return null;\n }\n }\n\n async setBackupDirectory(dirHandle) {\n this.backupDirHandle = dirHandle || null;\n this.backupDirPermissionGranted = !!dirHandle;\n \n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n if (dirHandle) {\n const putReq = configStore.put({ key: 'backup_directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = () => resolve();\n } else {\n const deleteReq = configStore.delete('backup_directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n }\n });\n }\n }\n\n async backupToFolder(dirHandle) {\n const handle = dirHandle || this.backupDirHandle;\n if (!handle) throw new Error('No backup directory configured.');\n \n const currentPerm = await handle.queryPermission({ mode: 'readwrite' });\n if (currentPerm !== 'granted') {\n const newPerm = await handle.requestPermission({ mode: 'readwrite' });\n if (newPerm !== 'granted') {\n throw new Error('Write permission denied for backup directory.');\n }\n }\n \n // Ensure company-specific folder exists within the selected directory\n const companyFolderHandle = await this.ensureCompanyFolderHandle(dirHandle);\n const dataDir = await companyFolderHandle.getDirectoryHandle('data', { create: true });\n const collections = Object.keys(TABLE_MAP);\n for (const col of collections) {\n const items = this.cache[col] || [];\n const fileHandle = await dataDir.getFileHandle(`${col}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items, null, 2));\n await writable.close();\n }\n \n // Update last backup timestamp\n localStorage.setItem('relay_last_backup_time', new Date().toISOString());\n this.backupDirPermissionGranted = true;\n }\n\n async setLocalDirectory(dirHandle) {\n this.dirHandle = dirHandle;\n if (dirHandle) {\n this.folderSyncEnabled = true;\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('folder_sync_enabled'), 'true');\n }\n \n // Save handle to IndexedDB\n if (this.db) {\n await new Promise((resolve, reject) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const putReq = configStore.put({ key: 'directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = (e) => reject(e.target.error);\n });\n }\n \n this.folderSyncPermissionGranted = true;\n \n // Force rewrite all cached collections to directory immediately\n const collections = Object.keys(TABLE_MAP);\n await Promise.all(collections.map(col => this.writeCollectionToFolder(col, this.cache[col])));\n } else {\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n \n // Remove handle from IndexedDB\n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const deleteReq = configStore.delete('directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n });\n }\n }\n }\n\n async writeCollectionToFolder(collection, items) {\n if (!this.folderSyncEnabled) return;\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/data/${collection}.json`,\n data: JSON.stringify(items || [], null, 2),\n directory: 'DOCUMENTS',\n encoding: 'utf8',\n recursive: true\n });\n } catch (err) {\n console.error(`Capacitor failed to write collection ${collection}:`, err);\n }\n return;\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return;\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return;\n const dataDir = await companyFolder.getDirectoryHandle('data', { create: true });\n const fileHandle = await dataDir.getFileHandle(`${collection}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items || [], null, 2));\n await writable.close();\n } catch (err) {\n console.error(`Failed to write collection ${collection} to local directory:`, err);\n }\n }\n\n async writeDocumentFileToFolder(docId, name, dataUrl) {\n if (!this.folderSyncEnabled) return null;\n\n const ext = name.includes('.') ? name.split('.').pop() : 'bin';\n const fileName = `${docId}.${ext}`;\n const relativePath = `documents/${fileName}`;\n\n // Extract base64 payload\n const base64Parts = dataUrl.split(',');\n if (base64Parts.length < 2) return null;\n const base64Data = base64Parts[1];\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/documents/${fileName}`,\n data: base64Data,\n directory: 'DOCUMENTS',\n recursive: true\n });\n return relativePath;\n } catch (err) {\n console.error('Capacitor failed to write document:', err);\n return null;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return null;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return null;\n\n const mimeMatch = base64Parts[0].match(/:(.*?);/);\n const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';\n const binaryStr = atob(base64Data);\n const len = binaryStr.length;\n const bytes = new Uint8Array(len);\n for (let i = 0; i < len; i++) {\n bytes[i] = binaryStr.charCodeAt(i);\n }\n const blob = new Blob([bytes], { type: mime });\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return null;\n const docsDir = await companyFolder.getDirectoryHandle('documents', { create: true });\n const fileHandle = await docsDir.getFileHandle(fileName, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(blob);\n await writable.close();\n\n return relativePath;\n } catch (err) {\n console.error('Failed to save document file to local directory:', err);\n return null;\n }\n }\n\n async getDocumentUrl(doc) {\n if (!doc || !doc.url) return '';\n if (doc.url.startsWith('data:') || doc.url.startsWith('http')) {\n return doc.url;\n }\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n const result = await Filesystem.readFile({\n path: `RelayDispatchData/${doc.url}`,\n directory: 'DOCUMENTS'\n });\n const mime = doc.type || 'application/octet-stream';\n return `data:${mime};base64,${result.data}`;\n } catch (err) {\n console.error('Capacitor failed to read document file:', err);\n throw err;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) {\n throw new Error('Local directory sync is not configured.');\n }\n\n try {\n const hasPerm = await this.verifyDirPermission(false);\n if (!hasPerm) {\n throw new Error('Permission to access the local directory was not granted.');\n }\n\n const parts = doc.url.split('/');\n const dirName = parts[0];\n const fileName = parts[1];\n\n const dir = await this.dirHandle.getDirectoryHandle(dirName);\n const fileHandle = await dir.getFileHandle(fileName);\n const file = await fileHandle.getFile();\n return URL.createObjectURL(file);\n } catch (err) {\n console.error('Failed to read file from local directory:', err);\n throw err;\n }\n }\n\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // Initialize IndexedDB for cloud users too, to store backup directory handles\n await this.initIndexedDB();\n\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id\n query = query.eq('company_id', this.companyId);\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed default user types if database is empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n // Unique channel name per subscribe — reusing a fixed name throws\n // \"cannot add postgres_changes callbacks ... after subscribe()\" when sync re-inits.\n const channel = supabase\n .channel(`table-db-changes-${this.companyId || 'anon'}-${Date.now()}`)\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n if (collection === 'contractors') {\n record.businessName = record.name;\n record.active = record.status === 'Active';\n }\n\n if (collection === 'suppliers') {\n record.active = record.status === 'Active';\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.project_id !== undefined) {\n record.projectId = record.project_id;\n delete record.project_id;\n }\n if (record.cost_center_id !== undefined) {\n record.costCenterId = record.cost_center_id;\n delete record.cost_center_id;\n }\n if (record.start_date !== undefined) {\n record.startDate = record.start_date;\n delete record.start_date;\n }\n if (record.end_date !== undefined) {\n record.endDate = record.end_date;\n delete record.end_date;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.license_number !== undefined) { record.licenseNumber = record.license_number; delete record.license_number; }\n if (record.hourly_rate !== undefined) { record.hourlyRate = parseFloat(record.hourly_rate); delete record.hourly_rate; }\n if (record.after_hours_rate !== undefined) { record.afterHoursRate = parseFloat(record.after_hours_rate); delete record.after_hours_rate; }\n if (record.callout_fee !== undefined) { record.calloutFee = parseFloat(record.callout_fee); delete record.callout_fee; }\n if (record.compliance_docs !== undefined) { record.complianceDocs = record.compliance_docs; delete record.compliance_docs; }\n if (record.account_number !== undefined) { record.accountNumber = record.account_number; delete record.account_number; }\n if (record.payment_terms !== undefined) { record.paymentTerms = record.payment_terms; delete record.payment_terms; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.day_offset !== undefined) { record.dayOffset = parseInt(record.day_offset); delete record.day_offset; }\n if (record.start_hour !== undefined) { record.startHour = parseFloat(record.start_hour); delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = parseFloat(record.end_hour); delete record.end_hour; }\n\n if (record.color && record.color.startsWith('__meta__:')) {\n try {\n const parsed = JSON.parse(record.color.substring(9));\n record.taskId = parsed.taskId;\n record.taskPath = parsed.taskId;\n record.taskName = parsed.taskName;\n record.color = parsed.color;\n } catch (e) {\n console.error(e);\n }\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (collection === 'contractors') {\n record.name = record.businessName;\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (collection === 'suppliers') {\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.projectId !== undefined) {\n record.project_id = record.projectId;\n delete record.projectId;\n }\n if (record.costCenterId !== undefined) {\n record.cost_center_id = record.costCenterId;\n delete record.costCenterId;\n }\n if (record.startDate !== undefined) {\n record.start_date = record.startDate;\n delete record.startDate;\n }\n if (record.endDate !== undefined) {\n record.end_date = record.endDate;\n delete record.endDate;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.licenseNumber !== undefined) { record.license_number = record.licenseNumber; delete record.licenseNumber; }\n if (record.hourlyRate !== undefined) { record.hourly_rate = record.hourlyRate; delete record.hourlyRate; }\n if (record.afterHoursRate !== undefined) { record.after_hours_rate = record.afterHoursRate; delete record.afterHoursRate; }\n if (record.calloutFee !== undefined) { record.callout_fee = record.calloutFee; delete record.calloutFee; }\n if (record.complianceDocs !== undefined) { record.compliance_docs = record.complianceDocs; delete record.complianceDocs; }\n if (record.accountNumber !== undefined) { record.account_number = record.accountNumber; delete record.accountNumber; }\n if (record.paymentTerms !== undefined) { record.payment_terms = record.paymentTerms; delete record.paymentTerms; }\n if (record.jobNumber !== undefined) { record.job_number = record.jobNumber; delete record.jobNumber; }\n if (record.dayOffset !== undefined) { record.day_offset = record.dayOffset; delete record.dayOffset; }\n if (record.startHour !== undefined) { record.start_hour = record.startHour; delete record.startHour; }\n if (record.endHour !== undefined) { record.end_hour = record.endHour; delete record.endHour; }\n\n if (collection === 'schedule') {\n const meta = {\n taskId: record.taskId || record.taskPath || null,\n taskName: record.taskName || null,\n color: record.color || null\n };\n record.color = '__meta__:' + JSON.stringify(meta);\n }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n const items = this.cache[collection] || [];\n if (collection === 'technicians') {\n const loginMode = typeof localStorage !== 'undefined' ? localStorage.getItem('relay_login_mode') : null;\n if (loginMode === 'local') {\n const currentUserStr = typeof localStorage !== 'undefined' ? localStorage.getItem('currentUser') : null;\n if (currentUserStr) {\n const currentUser = JSON.parse(currentUserStr);\n if (currentUser && currentUser.id) {\n return [{\n id: currentUser.id,\n name: currentUser.name || 'Local Admin',\n role: 'Administrator',\n color: currentUser.color || '#FF5C00',\n userTypeId: currentUser.userTypeId,\n email: currentUser.email || '',\n username: currentUser.username || ''\n }];\n }\n }\n }\n }\n return items;\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Surface a failed background write to the user instead of failing silently.\n // (A silent insert/update failure leaves the optimistic cache entry in place, so the\n // record looks saved until the next reload — the classic \"it didn't save\" symptom.)\n _notifyWriteError(action, collection, error) {\n console.error(`Error trying to ${action} ${collection}:`, error?.message || error, error);\n try {\n import('../components/Notifications.js')\n .then(({ showToast }) => showToast(\n `Couldn't ${action} ${collection.replace(/s$/, '')} — ${error?.message || 'database error'}`,\n 'error'))\n .catch(() => {});\n } catch (e) {}\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = this.getNextNumber('LD-', 'leads');\n }\n if (collection === 'notifications' && !item.number) {\n item.number = this.getNextNumber('NT-', 'notifications');\n }\n if (collection === 'invoices' && !item.number) {\n item.number = this.getNextNumber('INV-', 'invoices');\n }\n if (collection === 'quotes' && !item.number) {\n item.number = this.getNextNumber('Q-', 'quotes');\n }\n if (collection === 'jobs' && !item.number) {\n item.number = this.getNextNumber('J-', 'jobs');\n }\n if (collection === 'purchaseOrders' && !item.number) {\n item.number = this.getNextNumber('PO-', 'purchaseOrders');\n }\n if (collection === 'projects' && !item.number) {\n item.number = this.getNextNumber('PRJ-', 'projects');\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Local Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (this.folderSyncEnabled) {\n if (collection === 'documents' && item.url && item.url.startsWith('data:')) {\n this.writeDocumentFileToFolder(item.id, item.name, item.url).then(relPath => {\n if (relPath) {\n item.url = relPath;\n // Re-save in IndexedDB and rewrite folder collection with updated path\n this.writeRecordToIndexedDB(collection, item);\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n });\n } else {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n }\n this.writeRecordToIndexedDB(collection, item).catch(err => {\n console.error(`Error saving ${collection} to IndexedDB:`, err);\n });\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n item.email = email;\n\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) {\n // Roll back the optimistic add so the UI matches the database\n this.cache[collection] = (this.cache[collection] || []).filter(x => x.id !== item.id);\n this.emit(collection, this.cache[collection]);\n this._notifyWriteError('save', collection, error);\n }\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n this.writeRecordToIndexedDB(collection, updated).catch(err => {\n console.error(`Error saving updated ${collection} to IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.relay.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const { data, error: invokeError } = await supabase.functions.invoke('invite-user', {\n body: {\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n }\n });\n\n if (invokeError) {\n throw new Error(invokeError.message || JSON.stringify(invokeError));\n }\n if (!data || !data.success) {\n throw new Error(data?.error || 'Failed to update user.');\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Roll back to the previous version so the UI matches the database\n const arr = [...(this.cache[collection] || [])];\n const i = arr.findIndex(x => x.id === id);\n if (i !== -1) { arr[i] = previous; this.cache[collection] = arr; this.emit(collection, arr); }\n this._notifyWriteError('update', collection, error);\n }\n });\n }\n\n return updated;\n }\n\n delete(collection, id) {\n // 1. Update memory cache (keep a copy of the removed row for rollback)\n const removed = (this.cache[collection] || []).find(item => item.id === id);\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n this.deleteRecordFromIndexedDB(collection, id).catch(err => {\n console.error(`Error deleting ${id} from ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Restore the row we optimistically removed\n if (removed) { this.cache[collection] = [...(this.cache[collection] || []), removed]; this.emit(collection, this.cache[collection]); }\n this._notifyWriteError('delete', collection, error);\n }\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n /**\n * Generate the next sequential number for a collection.\n * Scans existing records, finds the highest numeric suffix, and returns prefix + (max+1) zero-padded to 5 digits.\n * e.g. getNextNumber('INV-', 'invoices') → 'INV-00001', 'INV-00002', ...\n */\n getNextNumber(defaultPrefix, collection) {\n const settings = this.getSettings();\n const dt = settings.documentTheme || {};\n \n let prefix = defaultPrefix;\n let startingNum = 1;\n\n if (collection === 'invoices') {\n prefix = dt.invoicePrefix !== undefined ? dt.invoicePrefix : 'INV-';\n startingNum = dt.invoiceStartingNumber !== undefined ? parseInt(dt.invoiceStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n } else if (collection === 'quotes') {\n prefix = dt.quotePrefix !== undefined ? dt.quotePrefix : 'Q-';\n startingNum = dt.quoteStartingNumber !== undefined ? parseInt(dt.quoteStartingNumber, 10) : 1;\n if (isNaN(startingNum)) startingNum = 1;\n }\n\n const items = this.getAll(collection) || [];\n let maxNum = startingNum - 1;\n\n items.forEach(item => {\n if (item.number && typeof item.number === 'string' && item.number.startsWith(prefix)) {\n const numStr = item.number.slice(prefix.length);\n const num = parseInt(numStr, 10);\n if (!isNaN(num) && num > maxNum) {\n maxNum = num;\n }\n }\n });\n\n return prefix + (maxNum + 1).toString().padStart(5, '0');\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: defaultLogoLarge,\n logoSmall: defaultLogoSmall,\n markupPercent: 20,\n taxEnabled: true,\n taxRate: 10,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n jobTypes: ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n },\n ai: {\n enabled: (this.companyId && !this.companyId.startsWith('acct_')) ? !!(typeof import.meta.env !== 'undefined' && (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY)) : false,\n apiKey: (this.companyId && !this.companyId.startsWith('acct_')) ? (typeof import.meta.env !== 'undefined' ? (import.meta.env?.VITE_AI_API_KEY || import.meta.env?.VITE_DEEPSEEK_API_KEY || '') : '') : '',\n endpoint: 'https://api.openai.com/v1/chat/completions',\n model: 'gpt-4o-mini',\n systemPrompt: 'You are Relay, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default logos if user has not uploaded custom ones\n if (!merged.logo || merged.logo.includes('logo-large.png')) merged.logo = defaultLogoLarge;\n if (!merged.logoSmall) merged.logoSmall = defaultLogoSmall;\n if (!merged.jobTypes) merged.jobTypes = [...defaultSettings.jobTypes];\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n merged.ai = { ...defaultSettings.ai, ...this.companySettings.ai };\n return merged;\n }\n\n return defaultSettings;\n }\n\n getTaxRate() {\n const settings = this.getSettings();\n if (settings.taxEnabled === false) return 0;\n const rate = settings.taxRate !== undefined ? Number(settings.taxRate) : 10;\n return rate / 100;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('settings'), JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId && !this.companyId.startsWith('acct_')) return true;\n return localStorage.getItem(this.getStorageKey('seeded')) === 'true';\n }\n\n save(collection, items) {\n const prev = this.cache[collection] || [];\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const p = this.writeAllToIndexedDB(collection, items).catch(err => {\n console.error(`Error saving all items to ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, items);\n }\n return p;\n }\n\n // Cloud mode: persist the whole collection. Upsert every current row, then delete\n // any rows that were removed from the array. (Previously this only touched the\n // in-memory cache, so every save() — assign-tech, role edits, form templates,\n // portal edits, stock receiving, mark-read, etc. — silently failed to persist.)\n const table = TABLE_MAP[collection];\n if (!table) return; // cache-only collections (e.g. 'activity') have no table\n\n const payload = items\n .filter(it => it && it.id)\n .map(it => this.denormalizeRecord({ ...it, companyId: this.companyId }, collection));\n if (payload.length) {\n supabase.from(table).upsert(payload).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n\n const currentIds = new Set(items.map(it => it && it.id));\n const removedIds = prev.filter(p => p && p.id && !currentIds.has(p.id)).map(p => p.id);\n if (removedIds.length) {\n supabase.from(table).delete().in('id', removedIds).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n }\n\n markSeeded() {\n localStorage.setItem(this.getStorageKey('seeded'), 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('formTemplates', templates);\n return;\n }\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n async seedDefaultUserTypes() {\n if (!this.companyId) return;\n\n const userTypes = [\n {\n id: `${this.companyId}_ut_admin`,\n name: 'Admin',\n description: 'Full system access',\n permissions: buildGranularPerms(() => true)\n },\n {\n id: `${this.companyId}_ut_manager`,\n name: 'Manager',\n description: 'Can manage most workflows but limited settings access',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company', 'manage_tax'].includes(key);\n return true;\n })\n },\n {\n id: `${this.companyId}_ut_tech`,\n name: 'Technician',\n description: 'Field staff — limited to their own jobs, schedule and timesheets',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n {\n id: `${this.companyId}_ut_office`,\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders', 'Suppliers'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n\n this.cache.userTypes = userTypes;\n this.emit('userTypes', userTypes);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('userTypes', userTypes);\n return;\n }\n\n const dbPayload = userTypes.map(ut => this.denormalizeRecord({ ...ut, companyId: this.companyId }, 'userTypes'));\n const { error } = await supabase.from('user_types').insert(dbPayload);\n if (error) console.error('Error seeding default user types:', error);\n }\n\n async seedDefaultTechnicians() {\n const companyId = this.companyId;\n if (!companyId) return;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const managerTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_manager` : 'ut_manager';\n const techTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_tech` : 'ut_tech';\n const officeTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_office` : 'ut_office';\n\n const defaultTechs = [\n { id: `${companyId}_tech_1`, name: 'Jake Morrow', role: 'Senior Electrician', color: '#3B82F6', userTypeId: adminTypeId, payRate: 95.00, email: 'jake@apexpowerservices.com.au', phone: '0412 233 445', username: 'jake', password: '123456' },\n { id: `${companyId}_tech_2`, name: 'Ryan Holt', role: 'Service Manager', color: '#10B981', userTypeId: managerTypeId, payRate: 85.00, email: 'ryan@apexpowerservices.com.au', phone: '0423 344 556', username: 'ryan', password: '123456' },\n { id: `${companyId}_tech_3`, name: 'Sandra Okafor', role: 'Electrician', color: '#8B5CF6', userTypeId: techTypeId, payRate: 80.00, email: 'sandra@apexpowerservices.com.au', phone: '0434 455 667', username: 'sandra', password: '123456' },\n { id: `${companyId}_tech_4`, name: 'Dean Caruso', role: 'Office Administrator',color: '#F59E0B', userTypeId: officeTypeId, payRate: 50.00, email: 'dean@apexpowerservices.com.au', phone: '0445 566 778', username: 'dean', password: '123456' }\n ];\n\n this.cache.technicians = defaultTechs;\n this.emit('technicians', defaultTechs);\n\n if (companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('technicians', defaultTechs);\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder('technicians', defaultTechs).catch(err => {\n console.error('Error writing seeded technicians to local folder:', err);\n });\n }\n return;\n }\n\n const dbPayload = defaultTechs.map(t => this.denormalizeRecord({ ...t, companyId }, 'technicians'));\n const { error } = await supabase.from('technicians').insert(dbPayload);\n if (error) console.error('Error seeding default technicians:', error);\n }\n\n async migrateLocalToCloud(companyId, adminUserId) {\n this.companyId = companyId;\n this.userId = adminUserId;\n\n // 1. Update the remote company settings with local companySettings\n const currentSettings = this.getSettings();\n const { error: compErr } = await supabase.from('companies').update({\n abn: currentSettings.abn || '',\n phone: currentSettings.phone || '',\n domain: currentSettings.domain || '',\n email: currentSettings.email || '',\n address: currentSettings.address || '',\n settings: currentSettings\n }).eq('id', companyId);\n if (compErr) console.error('Error updating cloud company settings during migration:', compErr);\n\n // 2. Loop through all collections and push to Supabase\n // Skip 'companies' and 'technicians'\n const collectionsToMigrate = Object.keys(TABLE_MAP).filter(col => col !== 'companies' && col !== 'technicians');\n\n for (const col of collectionsToMigrate) {\n const items = this.cache[col] || [];\n if (items.length === 0) continue;\n\n const table = TABLE_MAP[col];\n if (!table) continue;\n\n // Assign the new cloud company_id and denormalize\n const payload = items.map(item => {\n const itemCopy = { ...item, companyId };\n return this.denormalizeRecord(itemCopy, col);\n });\n\n // Write to Supabase using upsert\n const { error } = await supabase.from(table).upsert(payload);\n if (error) {\n console.error(`Error migrating collection ${col}:`, error);\n throw new Error(`Failed to migrate ${col} data: ${error.message}`);\n }\n }\n\n // 3. Clear/close local DB connection and sync from Cloud\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n }\n\n deleteLocalAccountData(accountId) {\n return new Promise((resolve) => {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n if (typeof localStorage !== 'undefined') {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem(`relay_${accountId}_${col}`);\n });\n localStorage.removeItem(`relay_${accountId}_folder_sync_enabled`);\n localStorage.removeItem(`relay_${accountId}_seeded`);\n localStorage.removeItem(`relay_${accountId}_settings`);\n }\n if (typeof window !== 'undefined' && window.indexedDB) {\n const req = window.indexedDB.deleteDatabase(`RelayDispatchDB_${accountId}`);\n req.onsuccess = () => resolve();\n req.onerror = () => resolve();\n req.onblocked = () => {\n console.warn('Database deletion blocked, resolving anyway');\n resolve();\n };\n } else {\n resolve();\n }\n });\n }\n\n async clearAll() {\n this.clearSync();\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n \n if (activeAccount) {\n await this.deleteLocalAccountData(activeAccount);\n } else {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n this.dirHandle = null;\n try {\n await this.clearAllIndexedDB();\n } catch (err) {\n console.error('Error clearing IndexedDB:', err);\n }\n }\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "1f07f8ee860408b426818f764ac4c1887ad010d4": {"t": 1785754189.9357681, "n": 92543, "h": "eb33b21356020518", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\nconst defaultLogoLarge = new URL('../assets/RELAY_Dispatch_Logo.png', import.meta.url).href;\nconst defaultLogoSmall = new URL('../assets/logo-small.png', import.meta.url).href;\n\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n passwordResetRequests: 'password_reset_requests',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents',\n leads: 'leads',\n schedule: 'schedule'\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"license_number\",\n \"hourly_rate\",\n \"after_hours_rate\",\n \"callout_fee\",\n \"specialties\",\n \"notes\",\n \"portal_token\",\n \"compliance_docs\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"address\",\n \"category\",\n \"account_number\",\n \"payment_terms\",\n \"notes\",\n \"attachments\",\n \"created_at\",\n \"updated_at\"\n ],\n leads: [\n \"id\",\n \"company_id\",\n \"number\",\n \"title\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"status\",\n \"source\",\n \"value\",\n \"description\",\n \"priority\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"created_at\",\n \"updated_at\"\n ],\n password_reset_requests: [\n \"id\",\n \"technician_id\",\n \"employee_id\",\n \"requested_at\",\n \"status\",\n \"token\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"type\",\n \"description\",\n \"priority\",\n \"read\",\n \"number\",\n \"asset_id\",\n \"job_id\",\n \"due_date\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g><text x=\"90\" y=\"36\" fill=\"#2D3134\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"25\" font-weight=\"900\" letter-spacing=\"0.04em\">DISPATCH</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nconst MODULE_PERMS = {\n 'Dashboard': [{ key: 'view' }],\n 'Customers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_contacts' }],\n 'Leads': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'convert' }],\n 'Quotes': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'approve' }, { key: 'convert' }, { key: 'generate_pdf' }],\n 'Jobs': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_tasks' }, { key: 'book_time' }, { key: 'view_costs' }, { key: 'view_quotes_tab' }, { key: 'view_pos_tab' }, { key: 'view_timesheets_tab' }, { key: 'view_invoices_tab' }, { key: 'manage_materials' }, { key: 'create_invoice' }],\n 'Timesheets': [{ key: 'view_own' }, { key: 'view' }, { key: 'create' }, { key: 'approve' }, { key: 'edit_all' }, { key: 'export' }],\n 'Assets': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Schedule': [{ key: 'view_own' }, { key: 'view' }, { key: 'edit' }],\n 'Contractors': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }],\n 'Suppliers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Stock': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Purchase Orders': [{ key: 'view' }, { key: 'create' }, { key: 'approve' }],\n 'Invoices': [{ key: 'view' }, { key: 'create' }, { key: 'send' }, { key: 'void' }],\n 'Reports': [{ key: 'view' }, { key: 'export' }],\n 'Documents': [{ key: 'view' }, { key: 'upload' }],\n 'Settings': [{ key: 'view' }, { key: 'edit_company' }, { key: 'manage_users' }, { key: 'manage_tax' }]\n};\n\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n this.db = null;\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n this.backupDirHandle = null;\n this.backupDirPermissionGranted = false;\n\n // Check if we have a cloud user at boot (before filling cache)\n const bootUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n const isCloudUser = !!(bootUser && bootUser.companyId && !bootUser.companyId.startsWith('acct_'));\n\n if (!isCloudUser && bootUser && bootUser.companyId && typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', bootUser.companyId);\n }\n\n // Pre-initialize empty arrays for collections.\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Load local settings if they exist (only for offline/demo mode)\n if (!isCloudUser && typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId && !this.companyId.startsWith('acct_')) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n // Only clear sync if we were previously in cloud sync mode or companyId is not defined\n if (!this.companyId || !this.companyId.startsWith('acct_')) {\n this.clearSync();\n }\n }\n });\n\n // Auto-trigger sync or local load if user already logged in at boot\n if (isCloudUser) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n this.initPromise = this.initializeCloudSync();\n } else {\n if (bootUser && bootUser.companyId) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n }\n this.initPromise = this.initializeLocalStore();\n }\n }\n\n getStorageKey(collection) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (activeAccount) {\n return `relay_${activeAccount}_${collection}`;\n }\n return `simpro_${collection}`;\n }\n\n getDBName() {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n return activeAccount ? `RelayDispatchDB_${activeAccount}` : 'RelayDispatchDB';\n }\n\n async initializeUser(user) {\n if (user && user.companyId && !user.companyId.startsWith('acct_')) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n } else {\n this.clearSync();\n if (user && user.companyId) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', user.companyId);\n }\n } else {\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n }\n this.initPromise = this.initializeLocalStore();\n await this.initPromise;\n }\n }\n\n async initializeLocalStore() {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n\n try {\n await this.initIndexedDB();\n } catch (e) {\n console.error('Failed to initialize IndexedDB, falling back to empty arrays:', e);\n }\n\n // Load local settings if they exist (namespaced)\n this.companySettings = null;\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n this.emit('settings', this.getSettings());\n\n // Seed default user types and forms if empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n if (this.cache.technicians.length === 0) {\n await this.seedDefaultTechnicians();\n }\n }\n\n initIndexedDB() {\n return new Promise((resolve, reject) => {\n if (typeof window === 'undefined' || !window.indexedDB) {\n return resolve();\n }\n\n const dbName = this.getDBName();\n const request = window.indexedDB.open(dbName, 2);\n\n request.onerror = (e) => {\n console.error('IndexedDB open error:', e.target.error);\n reject(e.target.error);\n };\n\n request.onsuccess = async (e) => {\n this.db = e.target.result;\n \n // 1. Load custom directory sync config from IndexedDB config store if it exists\n try {\n if (this.db.objectStoreNames.contains('config')) {\n await new Promise((resConfig) => {\n const transaction = this.db.transaction('config', 'readonly');\n const configStore = transaction.objectStore('config');\n \n const getSync = new Promise((resolve) => {\n const req = configStore.get('directory_handle');\n req.onsuccess = () => {\n console.log('[DEBUG Store] getSync onsuccess result:', req.result);\n if (req.result) {\n this.dirHandle = req.result.value;\n console.log('[DEBUG Store] loaded dirHandle:', this.dirHandle ? this.dirHandle.name : null);\n }\n resolve();\n };\n req.onerror = (e) => {\n console.error('[DEBUG Store] getSync onerror:', e);\n resolve();\n };\n });\n\n const getBackup = new Promise((resolve) => {\n const req = configStore.get('backup_directory_handle');\n req.onsuccess = () => {\n if (req.result) this.backupDirHandle = req.result.value;\n resolve();\n };\n req.onerror = () => resolve();\n });\n\n Promise.all([getSync, getBackup]).then(() => resConfig());\n });\n }\n } catch (err) {\n console.warn('Failed to read config from IndexedDB:', err);\n }\n\n // Verify directory permission silently on boot if handle exists\n if (this.dirHandle) {\n try {\n // Check permission silently without prompt dialog\n const opts = { mode: 'readwrite' };\n const status = await this.dirHandle.queryPermission(opts);\n this.folderSyncPermissionGranted = status === 'granted';\n } catch (err) {\n console.warn('Failed to query directory permission on boot:', err);\n }\n }\n\n // Verify backup directory permission silently on boot if handle exists\n if (this.backupDirHandle) {\n try {\n const opts = { mode: 'readwrite' };\n const status = await this.backupDirHandle.queryPermission(opts);\n this.backupDirPermissionGranted = status === 'granted';\n\n // Auto-trigger backup if granted and > 7 days since last backup\n if (this.backupDirPermissionGranted) {\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;\n if (!lastBackup || new Date(lastBackup).getTime() < sevenDaysAgo) {\n this.backupToFolder().catch(err => {\n console.error('Auto background backup failed:', err);\n });\n }\n }\n } catch (err) {\n console.warn('Failed to query backup directory permission on boot:', err);\n }\n }\n\n // Load all collections into cache\n const collections = Object.keys(TABLE_MAP);\n const loadPromises = collections.map(async (col) => {\n try {\n this.cache[col] = await this.readAllFromIndexedDB(col);\n } catch (err) {\n console.error(`Error loading collection ${col} from IndexedDB:`, err);\n this.cache[col] = [];\n }\n });\n\n await Promise.all(loadPromises);\n\n // Perform migration if we have old data in localStorage (only if not in a namespaced local account profile)\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (!activeAccount) {\n const migratePromises = collections.map(async (col) => {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n const items = JSON.parse(localData);\n if (Array.isArray(items) && items.length > 0) {\n // Write all items to IndexedDB\n await this.writeAllToIndexedDB(col, items);\n // Merge into cache if cache is empty\n if (this.cache[col].length === 0) {\n this.cache[col] = items;\n }\n }\n localStorage.removeItem('simpro_' + col);\n } catch (err) {\n console.error(`Migration error for ${col}:`, err);\n }\n }\n });\n\n await Promise.all(migratePromises);\n }\n resolve();\n };\n\n request.onupgradeneeded = (e) => {\n const db = e.target.result;\n const collections = Object.keys(TABLE_MAP);\n collections.forEach(col => {\n if (!db.objectStoreNames.contains(col)) {\n db.createObjectStore(col, { keyPath: 'id' });\n }\n });\n if (!db.objectStoreNames.contains('config')) {\n db.createObjectStore('config', { keyPath: 'key' });\n }\n };\n });\n }\n\n readAllFromIndexedDB(storeName) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve([]);\n try {\n const transaction = this.db.transaction(storeName, 'readonly');\n const store = transaction.objectStore(storeName);\n const getAllRequest = store.getAll();\n\n getAllRequest.onsuccess = () => {\n resolve(getAllRequest.result || []);\n };\n\n getAllRequest.onerror = (e) => {\n reject(e.target.error);\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeRecordToIndexedDB(storeName, record) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const putRequest = store.put(record);\n\n putRequest.onsuccess = () => resolve();\n putRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n deleteRecordFromIndexedDB(storeName, id) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const deleteRequest = store.delete(id);\n\n deleteRequest.onsuccess = () => resolve();\n deleteRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeAllToIndexedDB(storeName, items) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n \n // Clear existing\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n \n clearRequest.onsuccess = () => {\n let count = 0;\n if (items.length === 0) return resolve();\n \n items.forEach(item => {\n const putRequest = store.put(item);\n putRequest.onerror = (e) => reject(e.target.error);\n putRequest.onsuccess = () => {\n count++;\n if (count === items.length) {\n resolve();\n }\n };\n });\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n clearAllIndexedDB() {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const collections = Object.keys(TABLE_MAP);\n const transaction = this.db.transaction(collections, 'readwrite');\n let count = 0;\n \n collections.forEach(col => {\n const store = transaction.objectStore(col);\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n clearRequest.onsuccess = () => {\n count++;\n if (count === collections.length) {\n resolve();\n }\n };\n });\n } catch (err) {\n reject(err);\n }\n });\n }\n\n // ── Local Folder Sync / File System Access Driver ──────────────────────────\n\n async verifyDirPermission(readWrite) {\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n this.folderSyncPermissionGranted = true;\n return true; // Capacitor native has permissions checked at app level\n }\n\n if (!this.dirHandle) return false;\n const opts = {};\n if (readWrite) opts.mode = 'readwrite';\n \n try {\n const currentPerm = await this.dirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.folderSyncPermissionGranted = true;\n return true;\n }\n \n // Request permission (must be called within a user gesture handler)\n const newPerm = await this.dirHandle.requestPermission(opts);\n this.folderSyncPermissionGranted = newPerm === 'granted';\n return this.folderSyncPermissionGranted;\n } catch (e) {\n this.folderSyncPermissionGranted = false;\n return false;\n }\n }\n\n async verifyBackupDirPermission(requestInteractive) {\n if (!this.backupDirHandle) return false;\n const opts = { mode: 'readwrite' };\n try {\n const currentPerm = await this.backupDirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.backupDirPermissionGranted = true;\n return true;\n }\n if (requestInteractive) {\n const newPerm = await this.backupDirHandle.requestPermission(opts);\n this.backupDirPermissionGranted = newPerm === 'granted';\n return this.backupDirPermissionGranted;\n }\n this.backupDirPermissionGranted = false;\n return false;\n } catch (e) {\n this.backupDirPermissionGranted = false;\n return false;\n }\n }\n\n // Ensure a subfolder for the current company exists within the selected directory.\n async ensureCompanyFolderHandle(customDirHandle) {\n const baseHandle = customDirHandle || this.dirHandle;\n if (!baseHandle) return null;\n const rawName = this.companySettings?.name || this.companySettings?.id || 'Company';\n const safeName = rawName.replace(/[\\\\/:*?\"<>|]/g, '_');\n try {\n const companyHandle = await baseHandle.getDirectoryHandle(safeName, { create: true });\n return companyHandle;\n } catch (e) {\n console.error('Failed to get/create company folder:', e);\n return null;\n }\n }\n\n async setBackupDirectory(dirHandle) {\n this.backupDirHandle = dirHandle || null;\n this.backupDirPermissionGranted = !!dirHandle;\n \n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n if (dirHandle) {\n const putReq = configStore.put({ key: 'backup_directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = () => resolve();\n } else {\n const deleteReq = configStore.delete('backup_directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n }\n });\n }\n }\n\n async backupToFolder(dirHandle) {\n const handle = dirHandle || this.backupDirHandle;\n if (!handle) throw new Error('No backup directory configured.');\n \n const currentPerm = await handle.queryPermission({ mode: 'readwrite' });\n if (currentPerm !== 'granted') {\n const newPerm = await handle.requestPermission({ mode: 'readwrite' });\n if (newPerm !== 'granted') {\n throw new Error('Write permission denied for backup directory.');\n }\n }\n \n // Ensure company-specific folder exists within the selected directory\n const companyFolderHandle = await this.ensureCompanyFolderHandle(dirHandle);\n const dataDir = await companyFolderHandle.getDirectoryHandle('data', { create: true });\n const collections = Object.keys(TABLE_MAP);\n for (const col of collections) {\n const items = this.cache[col] || [];\n const fileHandle = await dataDir.getFileHandle(`${col}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items, null, 2));\n await writable.close();\n }\n \n // Update last backup timestamp\n localStorage.setItem('relay_last_backup_time', new Date().toISOString());\n this.backupDirPermissionGranted = true;\n }\n\n async setLocalDirectory(dirHandle) {\n this.dirHandle = dirHandle;\n if (dirHandle) {\n this.folderSyncEnabled = true;\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('folder_sync_enabled'), 'true');\n }\n \n // Save handle to IndexedDB\n if (this.db) {\n await new Promise((resolve, reject) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const putReq = configStore.put({ key: 'directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = (e) => reject(e.target.error);\n });\n }\n \n this.folderSyncPermissionGranted = true;\n \n // Force rewrite all cached collections to directory immediately\n const collections = Object.keys(TABLE_MAP);\n await Promise.all(collections.map(col => this.writeCollectionToFolder(col, this.cache[col])));\n } else {\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n \n // Remove handle from IndexedDB\n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const deleteReq = configStore.delete('directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n });\n }\n }\n }\n\n async writeCollectionToFolder(collection, items) {\n if (!this.folderSyncEnabled) return;\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/data/${collection}.json`,\n data: JSON.stringify(items || [], null, 2),\n directory: 'DOCUMENTS',\n encoding: 'utf8',\n recursive: true\n });\n } catch (err) {\n console.error(`Capacitor failed to write collection ${collection}:`, err);\n }\n return;\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return;\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return;\n const dataDir = await companyFolder.getDirectoryHandle('data', { create: true });\n const fileHandle = await dataDir.getFileHandle(`${collection}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items || [], null, 2));\n await writable.close();\n } catch (err) {\n console.error(`Failed to write collection ${collection} to local directory:`, err);\n }\n }\n\n async writeDocumentFileToFolder(docId, name, dataUrl) {\n if (!this.folderSyncEnabled) return null;\n\n const ext = name.includes('.') ? name.split('.').pop() : 'bin';\n const fileName = `${docId}.${ext}`;\n const relativePath = `documents/${fileName}`;\n\n // Extract base64 payload\n const base64Parts = dataUrl.split(',');\n if (base64Parts.length < 2) return null;\n const base64Data = base64Parts[1];\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/documents/${fileName}`,\n data: base64Data,\n directory: 'DOCUMENTS',\n recursive: true\n });\n return relativePath;\n } catch (err) {\n console.error('Capacitor failed to write document:', err);\n return null;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return null;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return null;\n\n const mimeMatch = base64Parts[0].match(/:(.*?);/);\n const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';\n const binaryStr = atob(base64Data);\n const len = binaryStr.length;\n const bytes = new Uint8Array(len);\n for (let i = 0; i < len; i++) {\n bytes[i] = binaryStr.charCodeAt(i);\n }\n const blob = new Blob([bytes], { type: mime });\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return null;\n const docsDir = await companyFolder.getDirectoryHandle('documents', { create: true });\n const fileHandle = await docsDir.getFileHandle(fileName, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(blob);\n await writable.close();\n\n return relativePath;\n } catch (err) {\n console.error('Failed to save document file to local directory:', err);\n return null;\n }\n }\n\n async getDocumentUrl(doc) {\n if (!doc || !doc.url) return '';\n if (doc.url.startsWith('data:') || doc.url.startsWith('http')) {\n return doc.url;\n }\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n const result = await Filesystem.readFile({\n path: `RelayDispatchData/${doc.url}`,\n directory: 'DOCUMENTS'\n });\n const mime = doc.type || 'application/octet-stream';\n return `data:${mime};base64,${result.data}`;\n } catch (err) {\n console.error('Capacitor failed to read document file:', err);\n throw err;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) {\n throw new Error('Local directory sync is not configured.');\n }\n\n try {\n const hasPerm = await this.verifyDirPermission(false);\n if (!hasPerm) {\n throw new Error('Permission to access the local directory was not granted.');\n }\n\n const parts = doc.url.split('/');\n const dirName = parts[0];\n const fileName = parts[1];\n\n const dir = await this.dirHandle.getDirectoryHandle(dirName);\n const fileHandle = await dir.getFileHandle(fileName);\n const file = await fileHandle.getFile();\n return URL.createObjectURL(file);\n } catch (err) {\n console.error('Failed to read file from local directory:', err);\n throw err;\n }\n }\n\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // Initialize IndexedDB for cloud users too, to store backup directory handles\n await this.initIndexedDB();\n\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id\n query = query.eq('company_id', this.companyId);\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed default user types if database is empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n // Unique channel name per subscribe — reusing a fixed name throws\n // \"cannot add postgres_changes callbacks ... after subscribe()\" when sync re-inits.\n const channel = supabase\n .channel(`table-db-changes-${this.companyId || 'anon'}-${Date.now()}`)\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n if (collection === 'contractors') {\n record.businessName = record.name;\n record.active = record.status === 'Active';\n }\n\n if (collection === 'suppliers') {\n record.active = record.status === 'Active';\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.license_number !== undefined) { record.licenseNumber = record.license_number; delete record.license_number; }\n if (record.hourly_rate !== undefined) { record.hourlyRate = parseFloat(record.hourly_rate); delete record.hourly_rate; }\n if (record.after_hours_rate !== undefined) { record.afterHoursRate = parseFloat(record.after_hours_rate); delete record.after_hours_rate; }\n if (record.callout_fee !== undefined) { record.calloutFee = parseFloat(record.callout_fee); delete record.callout_fee; }\n if (record.compliance_docs !== undefined) { record.complianceDocs = record.compliance_docs; delete record.compliance_docs; }\n if (record.account_number !== undefined) { record.accountNumber = record.account_number; delete record.account_number; }\n if (record.payment_terms !== undefined) { record.paymentTerms = record.payment_terms; delete record.payment_terms; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.day_offset !== undefined) { record.dayOffset = parseInt(record.day_offset); delete record.day_offset; }\n if (record.start_hour !== undefined) { record.startHour = parseFloat(record.start_hour); delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = parseFloat(record.end_hour); delete record.end_hour; }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (collection === 'contractors') {\n record.name = record.businessName;\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (collection === 'suppliers') {\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.licenseNumber !== undefined) { record.license_number = record.licenseNumber; delete record.licenseNumber; }\n if (record.hourlyRate !== undefined) { record.hourly_rate = record.hourlyRate; delete record.hourlyRate; }\n if (record.afterHoursRate !== undefined) { record.after_hours_rate = record.afterHoursRate; delete record.afterHoursRate; }\n if (record.calloutFee !== undefined) { record.callout_fee = record.calloutFee; delete record.calloutFee; }\n if (record.complianceDocs !== undefined) { record.compliance_docs = record.complianceDocs; delete record.complianceDocs; }\n if (record.accountNumber !== undefined) { record.account_number = record.accountNumber; delete record.accountNumber; }\n if (record.paymentTerms !== undefined) { record.payment_terms = record.paymentTerms; delete record.paymentTerms; }\n if (record.jobNumber !== undefined) { record.job_number = record.jobNumber; delete record.jobNumber; }\n if (record.dayOffset !== undefined) { record.day_offset = record.dayOffset; delete record.dayOffset; }\n if (record.startHour !== undefined) { record.start_hour = record.startHour; delete record.startHour; }\n if (record.endHour !== undefined) { record.end_hour = record.endHour; delete record.endHour; }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n const items = this.cache[collection] || [];\n if (collection === 'technicians') {\n const loginMode = typeof localStorage !== 'undefined' ? localStorage.getItem('relay_login_mode') : null;\n if (loginMode === 'local') {\n const currentUserStr = typeof localStorage !== 'undefined' ? localStorage.getItem('currentUser') : null;\n if (currentUserStr) {\n const currentUser = JSON.parse(currentUserStr);\n if (currentUser && currentUser.id) {\n return [{\n id: currentUser.id,\n name: currentUser.name || 'Local Admin',\n role: 'Administrator',\n color: currentUser.color || '#FF5C00',\n userTypeId: currentUser.userTypeId,\n email: currentUser.email || '',\n username: currentUser.username || ''\n }];\n }\n }\n }\n }\n return items;\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Surface a failed background write to the user instead of failing silently.\n // (A silent insert/update failure leaves the optimistic cache entry in place, so the\n // record looks saved until the next reload — the classic \"it didn't save\" symptom.)\n _notifyWriteError(action, collection, error) {\n console.error(`Error trying to ${action} ${collection}:`, error?.message || error, error);\n try {\n import('../components/Notifications.js')\n .then(({ showToast }) => showToast(\n `Couldn't ${action} ${collection.replace(/s$/, '')} — ${error?.message || 'database error'}`,\n 'error'))\n .catch(() => {});\n } catch (e) {}\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = this.getNextNumber('LD-', 'leads');\n }\n if (collection === 'notifications' && !item.number) {\n item.number = this.getNextNumber('NT-', 'notifications');\n }\n if (collection === 'invoices' && !item.number) {\n item.number = this.getNextNumber('INV-', 'invoices');\n }\n if (collection === 'quotes' && !item.number) {\n item.number = this.getNextNumber('Q-', 'quotes');\n }\n if (collection === 'jobs' && !item.number) {\n item.number = this.getNextNumber('J-', 'jobs');\n }\n if (collection === 'purchaseOrders' && !item.number) {\n item.number = this.getNextNumber('PO-', 'purchaseOrders');\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Local Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (this.folderSyncEnabled) {\n if (collection === 'documents' && item.url && item.url.startsWith('data:')) {\n this.writeDocumentFileToFolder(item.id, item.name, item.url).then(relPath => {\n if (relPath) {\n item.url = relPath;\n // Re-save in IndexedDB and rewrite folder collection with updated path\n this.writeRecordToIndexedDB(collection, item);\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n });\n } else {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n }\n this.writeRecordToIndexedDB(collection, item).catch(err => {\n console.error(`Error saving ${collection} to IndexedDB:`, err);\n });\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n item.email = email;\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n })\n });\n\n let data;\n const text = await res.text();\n try {\n data = text ? JSON.parse(text) : {};\n } catch (e) {\n throw new Error(`Server returned status ${res.status}. Response: ${text.substring(0, 150) || '[Empty]'}`);\n }\n if (!res.ok || !data.success) {\n throw new Error(data.error || `Failed to create user (Status ${res.status}).`);\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) {\n // Roll back the optimistic add so the UI matches the database\n this.cache[collection] = (this.cache[collection] || []).filter(x => x.id !== item.id);\n this.emit(collection, this.cache[collection]);\n this._notifyWriteError('save', collection, error);\n }\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n this.writeRecordToIndexedDB(collection, updated).catch(err => {\n console.error(`Error saving updated ${collection} to IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n })\n });\n\n let data;\n const text = await res.text();\n try {\n data = text ? JSON.parse(text) : {};\n } catch (e) {\n throw new Error(`Server returned status ${res.status}. Response: ${text.substring(0, 150) || '[Empty]'}`);\n }\n if (!res.ok || !data.success) {\n throw new Error(data.error || `Failed to update user (Status ${res.status}).`);\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Roll back to the previous version so the UI matches the database\n const arr = [...(this.cache[collection] || [])];\n const i = arr.findIndex(x => x.id === id);\n if (i !== -1) { arr[i] = previous; this.cache[collection] = arr; this.emit(collection, arr); }\n this._notifyWriteError('update', collection, error);\n }\n });\n }\n\n return updated;\n }\n\n delete(collection, id) {\n // 1. Update memory cache (keep a copy of the removed row for rollback)\n const removed = (this.cache[collection] || []).find(item => item.id === id);\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n this.deleteRecordFromIndexedDB(collection, id).catch(err => {\n console.error(`Error deleting ${id} from ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Restore the row we optimistically removed\n if (removed) { this.cache[collection] = [...(this.cache[collection] || []), removed]; this.emit(collection, this.cache[collection]); }\n this._notifyWriteError('delete', collection, error);\n }\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n /**\n * Generate the next sequential number for a collection.\n * Scans existing records, finds the highest numeric suffix, and returns prefix + (max+1) zero-padded to 5 digits.\n * e.g. getNextNumber('INV-', 'invoices') → 'INV-00001', 'INV-00002', ...\n */\n getNextNumber(prefix, collection) {\n const items = this.getAll(collection) || [];\n let maxNum = 0;\n\n items.forEach(item => {\n if (item.number && typeof item.number === 'string' && item.number.startsWith(prefix)) {\n const numStr = item.number.slice(prefix.length);\n const num = parseInt(numStr, 10);\n if (!isNaN(num) && num > maxNum) {\n maxNum = num;\n }\n }\n });\n\n return prefix + (maxNum + 1).toString().padStart(5, '0');\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: defaultLogoLarge,\n logoSmall: defaultLogoSmall,\n markupPercent: 20,\n taxEnabled: true,\n taxRate: 10,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!'\n },\n ai: {\n enabled: !!(import.meta.env.VITE_DEEPSEEK_API_KEY),\n apiKey: import.meta.env.VITE_DEEPSEEK_API_KEY || '',\n endpoint: 'https://api.deepseek.com/chat/completions',\n model: 'deepseek-chat',\n systemPrompt: 'You are Relay, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default logos if user has not uploaded custom ones\n if (!merged.logo || merged.logo.includes('logo-large.png')) merged.logo = defaultLogoLarge;\n if (!merged.logoSmall) merged.logoSmall = defaultLogoSmall;\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n merged.ai = { ...defaultSettings.ai, ...this.companySettings.ai };\n return merged;\n }\n\n return defaultSettings;\n }\n\n getTaxRate() {\n const settings = this.getSettings();\n if (settings.taxEnabled === false) return 0;\n const rate = settings.taxRate !== undefined ? Number(settings.taxRate) : 10;\n return rate / 100;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('settings'), JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId && !this.companyId.startsWith('acct_')) return true;\n return localStorage.getItem(this.getStorageKey('seeded')) === 'true';\n }\n\n save(collection, items) {\n const prev = this.cache[collection] || [];\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const p = this.writeAllToIndexedDB(collection, items).catch(err => {\n console.error(`Error saving all items to ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, items);\n }\n return p;\n }\n\n // Cloud mode: persist the whole collection. Upsert every current row, then delete\n // any rows that were removed from the array. (Previously this only touched the\n // in-memory cache, so every save() — assign-tech, role edits, form templates,\n // portal edits, stock receiving, mark-read, etc. — silently failed to persist.)\n const table = TABLE_MAP[collection];\n if (!table) return; // cache-only collections (e.g. 'activity') have no table\n\n const payload = items\n .filter(it => it && it.id)\n .map(it => this.denormalizeRecord({ ...it, companyId: this.companyId }, collection));\n if (payload.length) {\n supabase.from(table).upsert(payload).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n\n const currentIds = new Set(items.map(it => it && it.id));\n const removedIds = prev.filter(p => p && p.id && !currentIds.has(p.id)).map(p => p.id);\n if (removedIds.length) {\n supabase.from(table).delete().in('id', removedIds).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n }\n\n markSeeded() {\n localStorage.setItem(this.getStorageKey('seeded'), 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('formTemplates', templates);\n return;\n }\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n async seedDefaultUserTypes() {\n if (!this.companyId) return;\n\n const userTypes = [\n {\n id: `${this.companyId}_ut_admin`,\n name: 'Admin',\n description: 'Full system access',\n permissions: buildGranularPerms(() => true)\n },\n {\n id: `${this.companyId}_ut_manager`,\n name: 'Manager',\n description: 'Can manage most workflows but limited settings access',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company', 'manage_tax'].includes(key);\n return true;\n })\n },\n {\n id: `${this.companyId}_ut_tech`,\n name: 'Technician',\n description: 'Field staff — limited to their own jobs, schedule and timesheets',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n {\n id: `${this.companyId}_ut_office`,\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders', 'Suppliers'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n\n this.cache.userTypes = userTypes;\n this.emit('userTypes', userTypes);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('userTypes', userTypes);\n return;\n }\n\n const dbPayload = userTypes.map(ut => this.denormalizeRecord({ ...ut, companyId: this.companyId }, 'userTypes'));\n const { error } = await supabase.from('user_types').insert(dbPayload);\n if (error) console.error('Error seeding default user types:', error);\n }\n\n async seedDefaultTechnicians() {\n const companyId = this.companyId;\n if (!companyId) return;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const managerTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_manager` : 'ut_manager';\n const techTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_tech` : 'ut_tech';\n const officeTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_office` : 'ut_office';\n\n const defaultTechs = [\n { id: `${companyId}_tech_1`, name: 'Jake Morrow', role: 'Senior Electrician', color: '#3B82F6', userTypeId: adminTypeId, payRate: 95.00, email: 'jake@apexpowerservices.com.au', phone: '0412 233 445', username: 'jake', password: '123456' },\n { id: `${companyId}_tech_2`, name: 'Ryan Holt', role: 'Service Manager', color: '#10B981', userTypeId: managerTypeId, payRate: 85.00, email: 'ryan@apexpowerservices.com.au', phone: '0423 344 556', username: 'ryan', password: '123456' },\n { id: `${companyId}_tech_3`, name: 'Sandra Okafor', role: 'Electrician', color: '#8B5CF6', userTypeId: techTypeId, payRate: 80.00, email: 'sandra@apexpowerservices.com.au', phone: '0434 455 667', username: 'sandra', password: '123456' },\n { id: `${companyId}_tech_4`, name: 'Dean Caruso', role: 'Office Administrator',color: '#F59E0B', userTypeId: officeTypeId, payRate: 50.00, email: 'dean@apexpowerservices.com.au', phone: '0445 566 778', username: 'dean', password: '123456' }\n ];\n\n this.cache.technicians = defaultTechs;\n this.emit('technicians', defaultTechs);\n\n if (companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('technicians', defaultTechs);\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder('technicians', defaultTechs).catch(err => {\n console.error('Error writing seeded technicians to local folder:', err);\n });\n }\n return;\n }\n\n const dbPayload = defaultTechs.map(t => this.denormalizeRecord({ ...t, companyId }, 'technicians'));\n const { error } = await supabase.from('technicians').insert(dbPayload);\n if (error) console.error('Error seeding default technicians:', error);\n }\n\n async migrateLocalToCloud(companyId, adminUserId) {\n this.companyId = companyId;\n this.userId = adminUserId;\n\n // 1. Update the remote company settings with local companySettings\n const currentSettings = this.getSettings();\n const { error: compErr } = await supabase.from('companies').update({\n abn: currentSettings.abn || '',\n phone: currentSettings.phone || '',\n domain: currentSettings.domain || '',\n email: currentSettings.email || '',\n address: currentSettings.address || '',\n settings: currentSettings\n }).eq('id', companyId);\n if (compErr) console.error('Error updating cloud company settings during migration:', compErr);\n\n // 2. Loop through all collections and push to Supabase\n // Skip 'companies' and 'technicians'\n const collectionsToMigrate = Object.keys(TABLE_MAP).filter(col => col !== 'companies' && col !== 'technicians');\n\n for (const col of collectionsToMigrate) {\n const items = this.cache[col] || [];\n if (items.length === 0) continue;\n\n const table = TABLE_MAP[col];\n if (!table) continue;\n\n // Assign the new cloud company_id and denormalize\n const payload = items.map(item => {\n const itemCopy = { ...item, companyId };\n return this.denormalizeRecord(itemCopy, col);\n });\n\n // Write to Supabase using upsert\n const { error } = await supabase.from(table).upsert(payload);\n if (error) {\n console.error(`Error migrating collection ${col}:`, error);\n throw new Error(`Failed to migrate ${col} data: ${error.message}`);\n }\n }\n\n // 3. Clear/close local DB connection and sync from Cloud\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n }\n\n deleteLocalAccountData(accountId) {\n return new Promise((resolve) => {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n if (typeof localStorage !== 'undefined') {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem(`relay_${accountId}_${col}`);\n });\n localStorage.removeItem(`relay_${accountId}_folder_sync_enabled`);\n localStorage.removeItem(`relay_${accountId}_seeded`);\n localStorage.removeItem(`relay_${accountId}_settings`);\n }\n if (typeof window !== 'undefined' && window.indexedDB) {\n const req = window.indexedDB.deleteDatabase(`RelayDispatchDB_${accountId}`);\n req.onsuccess = () => resolve();\n req.onerror = () => resolve();\n req.onblocked = () => {\n console.warn('Database deletion blocked, resolving anyway');\n resolve();\n };\n } else {\n resolve();\n }\n });\n }\n\n async clearAll() {\n this.clearSync();\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n \n if (activeAccount) {\n await this.deleteLocalAccountData(activeAccount);\n } else {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n this.dirHandle = null;\n try {\n await this.clearAllIndexedDB();\n } catch (err) {\n console.error('Error clearing IndexedDB:', err);\n }\n }\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "d2d059ebb7e6818c7cf09140accf6564902469ee": {"t": 1785754191.7171724, "n": 92087, "h": "4f03fc5e1d320b60", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\nconst defaultLogoLarge = new URL('../assets/RELAY_Dispatch_Logo.png', import.meta.url).href;\nconst defaultLogoSmall = new URL('../assets/logo-small.png', import.meta.url).href;\n\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n passwordResetRequests: 'password_reset_requests',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents',\n leads: 'leads',\n schedule: 'schedule'\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"license_number\",\n \"hourly_rate\",\n \"after_hours_rate\",\n \"callout_fee\",\n \"specialties\",\n \"notes\",\n \"portal_token\",\n \"compliance_docs\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"address\",\n \"category\",\n \"account_number\",\n \"payment_terms\",\n \"notes\",\n \"attachments\",\n \"created_at\",\n \"updated_at\"\n ],\n leads: [\n \"id\",\n \"company_id\",\n \"number\",\n \"title\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"status\",\n \"source\",\n \"value\",\n \"description\",\n \"priority\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"created_at\",\n \"updated_at\"\n ],\n password_reset_requests: [\n \"id\",\n \"technician_id\",\n \"employee_id\",\n \"requested_at\",\n \"status\",\n \"token\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"type\",\n \"description\",\n \"priority\",\n \"read\",\n \"number\",\n \"asset_id\",\n \"job_id\",\n \"due_date\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g><text x=\"90\" y=\"36\" fill=\"#2D3134\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"25\" font-weight=\"900\" letter-spacing=\"0.04em\">DISPATCH</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nconst MODULE_PERMS = {\n 'Dashboard': [{ key: 'view' }],\n 'Customers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_contacts' }],\n 'Leads': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'convert' }],\n 'Quotes': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'approve' }, { key: 'convert' }, { key: 'generate_pdf' }],\n 'Jobs': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_tasks' }, { key: 'book_time' }, { key: 'view_costs' }, { key: 'view_quotes_tab' }, { key: 'view_pos_tab' }, { key: 'view_timesheets_tab' }, { key: 'view_invoices_tab' }, { key: 'manage_materials' }, { key: 'create_invoice' }],\n 'Timesheets': [{ key: 'view_own' }, { key: 'view' }, { key: 'create' }, { key: 'approve' }, { key: 'edit_all' }, { key: 'export' }],\n 'Assets': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Schedule': [{ key: 'view_own' }, { key: 'view' }, { key: 'edit' }],\n 'Contractors': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }],\n 'Suppliers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Stock': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Purchase Orders': [{ key: 'view' }, { key: 'create' }, { key: 'approve' }],\n 'Invoices': [{ key: 'view' }, { key: 'create' }, { key: 'send' }, { key: 'void' }],\n 'Reports': [{ key: 'view' }, { key: 'export' }],\n 'Documents': [{ key: 'view' }, { key: 'upload' }],\n 'Settings': [{ key: 'view' }, { key: 'edit_company' }, { key: 'manage_users' }, { key: 'manage_tax' }]\n};\n\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n this.db = null;\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n this.backupDirHandle = null;\n this.backupDirPermissionGranted = false;\n\n // Check if we have a cloud user at boot (before filling cache)\n const bootUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n const isCloudUser = !!(bootUser && bootUser.companyId && !bootUser.companyId.startsWith('acct_'));\n\n if (!isCloudUser && bootUser && bootUser.companyId && typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', bootUser.companyId);\n }\n\n // Pre-initialize empty arrays for collections.\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Load local settings if they exist (only for offline/demo mode)\n if (!isCloudUser && typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId && !this.companyId.startsWith('acct_')) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n // Only clear sync if we were previously in cloud sync mode or companyId is not defined\n if (!this.companyId || !this.companyId.startsWith('acct_')) {\n this.clearSync();\n }\n }\n });\n\n // Auto-trigger sync or local load if user already logged in at boot\n if (isCloudUser) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n this.initPromise = this.initializeCloudSync();\n } else {\n if (bootUser && bootUser.companyId) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n }\n this.initPromise = this.initializeLocalStore();\n }\n }\n\n getStorageKey(collection) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (activeAccount) {\n return `relay_${activeAccount}_${collection}`;\n }\n return `simpro_${collection}`;\n }\n\n getDBName() {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n return activeAccount ? `RelayDispatchDB_${activeAccount}` : 'RelayDispatchDB';\n }\n\n async initializeUser(user) {\n if (user && user.companyId && !user.companyId.startsWith('acct_')) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n } else {\n this.clearSync();\n if (user && user.companyId) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', user.companyId);\n }\n } else {\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n }\n this.initPromise = this.initializeLocalStore();\n await this.initPromise;\n }\n }\n\n async initializeLocalStore() {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n\n try {\n await this.initIndexedDB();\n } catch (e) {\n console.error('Failed to initialize IndexedDB, falling back to empty arrays:', e);\n }\n\n // Load local settings if they exist (namespaced)\n this.companySettings = null;\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n this.emit('settings', this.getSettings());\n\n // Seed default user types and forms if empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n if (this.cache.technicians.length === 0) {\n await this.seedDefaultTechnicians();\n }\n }\n\n initIndexedDB() {\n return new Promise((resolve, reject) => {\n if (typeof window === 'undefined' || !window.indexedDB) {\n return resolve();\n }\n\n const dbName = this.getDBName();\n const request = window.indexedDB.open(dbName, 2);\n\n request.onerror = (e) => {\n console.error('IndexedDB open error:', e.target.error);\n reject(e.target.error);\n };\n\n request.onsuccess = async (e) => {\n this.db = e.target.result;\n \n // 1. Load custom directory sync config from IndexedDB config store if it exists\n try {\n if (this.db.objectStoreNames.contains('config')) {\n await new Promise((resConfig) => {\n const transaction = this.db.transaction('config', 'readonly');\n const configStore = transaction.objectStore('config');\n \n const getSync = new Promise((resolve) => {\n const req = configStore.get('directory_handle');\n req.onsuccess = () => {\n console.log('[DEBUG Store] getSync onsuccess result:', req.result);\n if (req.result) {\n this.dirHandle = req.result.value;\n console.log('[DEBUG Store] loaded dirHandle:', this.dirHandle ? this.dirHandle.name : null);\n }\n resolve();\n };\n req.onerror = (e) => {\n console.error('[DEBUG Store] getSync onerror:', e);\n resolve();\n };\n });\n\n const getBackup = new Promise((resolve) => {\n const req = configStore.get('backup_directory_handle');\n req.onsuccess = () => {\n if (req.result) this.backupDirHandle = req.result.value;\n resolve();\n };\n req.onerror = () => resolve();\n });\n\n Promise.all([getSync, getBackup]).then(() => resConfig());\n });\n }\n } catch (err) {\n console.warn('Failed to read config from IndexedDB:', err);\n }\n\n // Verify directory permission silently on boot if handle exists\n if (this.dirHandle) {\n try {\n // Check permission silently without prompt dialog\n const opts = { mode: 'readwrite' };\n const status = await this.dirHandle.queryPermission(opts);\n this.folderSyncPermissionGranted = status === 'granted';\n } catch (err) {\n console.warn('Failed to query directory permission on boot:', err);\n }\n }\n\n // Verify backup directory permission silently on boot if handle exists\n if (this.backupDirHandle) {\n try {\n const opts = { mode: 'readwrite' };\n const status = await this.backupDirHandle.queryPermission(opts);\n this.backupDirPermissionGranted = status === 'granted';\n\n // Auto-trigger backup if granted and > 7 days since last backup\n if (this.backupDirPermissionGranted) {\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;\n if (!lastBackup || new Date(lastBackup).getTime() < sevenDaysAgo) {\n this.backupToFolder().catch(err => {\n console.error('Auto background backup failed:', err);\n });\n }\n }\n } catch (err) {\n console.warn('Failed to query backup directory permission on boot:', err);\n }\n }\n\n // Load all collections into cache\n const collections = Object.keys(TABLE_MAP);\n const loadPromises = collections.map(async (col) => {\n try {\n this.cache[col] = await this.readAllFromIndexedDB(col);\n } catch (err) {\n console.error(`Error loading collection ${col} from IndexedDB:`, err);\n this.cache[col] = [];\n }\n });\n\n await Promise.all(loadPromises);\n\n // Perform migration if we have old data in localStorage (only if not in a namespaced local account profile)\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (!activeAccount) {\n const migratePromises = collections.map(async (col) => {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n const items = JSON.parse(localData);\n if (Array.isArray(items) && items.length > 0) {\n // Write all items to IndexedDB\n await this.writeAllToIndexedDB(col, items);\n // Merge into cache if cache is empty\n if (this.cache[col].length === 0) {\n this.cache[col] = items;\n }\n }\n localStorage.removeItem('simpro_' + col);\n } catch (err) {\n console.error(`Migration error for ${col}:`, err);\n }\n }\n });\n\n await Promise.all(migratePromises);\n }\n resolve();\n };\n\n request.onupgradeneeded = (e) => {\n const db = e.target.result;\n const collections = Object.keys(TABLE_MAP);\n collections.forEach(col => {\n if (!db.objectStoreNames.contains(col)) {\n db.createObjectStore(col, { keyPath: 'id' });\n }\n });\n if (!db.objectStoreNames.contains('config')) {\n db.createObjectStore('config', { keyPath: 'key' });\n }\n };\n });\n }\n\n readAllFromIndexedDB(storeName) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve([]);\n try {\n const transaction = this.db.transaction(storeName, 'readonly');\n const store = transaction.objectStore(storeName);\n const getAllRequest = store.getAll();\n\n getAllRequest.onsuccess = () => {\n resolve(getAllRequest.result || []);\n };\n\n getAllRequest.onerror = (e) => {\n reject(e.target.error);\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeRecordToIndexedDB(storeName, record) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const putRequest = store.put(record);\n\n putRequest.onsuccess = () => resolve();\n putRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n deleteRecordFromIndexedDB(storeName, id) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const deleteRequest = store.delete(id);\n\n deleteRequest.onsuccess = () => resolve();\n deleteRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeAllToIndexedDB(storeName, items) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n \n // Clear existing\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n \n clearRequest.onsuccess = () => {\n let count = 0;\n if (items.length === 0) return resolve();\n \n items.forEach(item => {\n const putRequest = store.put(item);\n putRequest.onerror = (e) => reject(e.target.error);\n putRequest.onsuccess = () => {\n count++;\n if (count === items.length) {\n resolve();\n }\n };\n });\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n clearAllIndexedDB() {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const collections = Object.keys(TABLE_MAP);\n const transaction = this.db.transaction(collections, 'readwrite');\n let count = 0;\n \n collections.forEach(col => {\n const store = transaction.objectStore(col);\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n clearRequest.onsuccess = () => {\n count++;\n if (count === collections.length) {\n resolve();\n }\n };\n });\n } catch (err) {\n reject(err);\n }\n });\n }\n\n // ── Local Folder Sync / File System Access Driver ──────────────────────────\n\n async verifyDirPermission(readWrite) {\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n this.folderSyncPermissionGranted = true;\n return true; // Capacitor native has permissions checked at app level\n }\n\n if (!this.dirHandle) return false;\n const opts = {};\n if (readWrite) opts.mode = 'readwrite';\n \n try {\n const currentPerm = await this.dirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.folderSyncPermissionGranted = true;\n return true;\n }\n \n // Request permission (must be called within a user gesture handler)\n const newPerm = await this.dirHandle.requestPermission(opts);\n this.folderSyncPermissionGranted = newPerm === 'granted';\n return this.folderSyncPermissionGranted;\n } catch (e) {\n this.folderSyncPermissionGranted = false;\n return false;\n }\n }\n\n async verifyBackupDirPermission(requestInteractive) {\n if (!this.backupDirHandle) return false;\n const opts = { mode: 'readwrite' };\n try {\n const currentPerm = await this.backupDirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.backupDirPermissionGranted = true;\n return true;\n }\n if (requestInteractive) {\n const newPerm = await this.backupDirHandle.requestPermission(opts);\n this.backupDirPermissionGranted = newPerm === 'granted';\n return this.backupDirPermissionGranted;\n }\n this.backupDirPermissionGranted = false;\n return false;\n } catch (e) {\n this.backupDirPermissionGranted = false;\n return false;\n }\n }\n\n // Ensure a subfolder for the current company exists within the selected directory.\n async ensureCompanyFolderHandle(customDirHandle) {\n const baseHandle = customDirHandle || this.dirHandle;\n if (!baseHandle) return null;\n const rawName = this.companySettings?.name || this.companySettings?.id || 'Company';\n const safeName = rawName.replace(/[\\\\/:*?\"<>|]/g, '_');\n try {\n const companyHandle = await baseHandle.getDirectoryHandle(safeName, { create: true });\n return companyHandle;\n } catch (e) {\n console.error('Failed to get/create company folder:', e);\n return null;\n }\n }\n\n async setBackupDirectory(dirHandle) {\n this.backupDirHandle = dirHandle || null;\n this.backupDirPermissionGranted = !!dirHandle;\n \n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n if (dirHandle) {\n const putReq = configStore.put({ key: 'backup_directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = () => resolve();\n } else {\n const deleteReq = configStore.delete('backup_directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n }\n });\n }\n }\n\n async backupToFolder(dirHandle) {\n const handle = dirHandle || this.backupDirHandle;\n if (!handle) throw new Error('No backup directory configured.');\n \n const currentPerm = await handle.queryPermission({ mode: 'readwrite' });\n if (currentPerm !== 'granted') {\n const newPerm = await handle.requestPermission({ mode: 'readwrite' });\n if (newPerm !== 'granted') {\n throw new Error('Write permission denied for backup directory.');\n }\n }\n \n // Ensure company-specific folder exists within the selected directory\n const companyFolderHandle = await this.ensureCompanyFolderHandle(dirHandle);\n const dataDir = await companyFolderHandle.getDirectoryHandle('data', { create: true });\n const collections = Object.keys(TABLE_MAP);\n for (const col of collections) {\n const items = this.cache[col] || [];\n const fileHandle = await dataDir.getFileHandle(`${col}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items, null, 2));\n await writable.close();\n }\n \n // Update last backup timestamp\n localStorage.setItem('relay_last_backup_time', new Date().toISOString());\n this.backupDirPermissionGranted = true;\n }\n\n async setLocalDirectory(dirHandle) {\n this.dirHandle = dirHandle;\n if (dirHandle) {\n this.folderSyncEnabled = true;\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('folder_sync_enabled'), 'true');\n }\n \n // Save handle to IndexedDB\n if (this.db) {\n await new Promise((resolve, reject) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const putReq = configStore.put({ key: 'directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = (e) => reject(e.target.error);\n });\n }\n \n this.folderSyncPermissionGranted = true;\n \n // Force rewrite all cached collections to directory immediately\n const collections = Object.keys(TABLE_MAP);\n await Promise.all(collections.map(col => this.writeCollectionToFolder(col, this.cache[col])));\n } else {\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n \n // Remove handle from IndexedDB\n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const deleteReq = configStore.delete('directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n });\n }\n }\n }\n\n async writeCollectionToFolder(collection, items) {\n if (!this.folderSyncEnabled) return;\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/data/${collection}.json`,\n data: JSON.stringify(items || [], null, 2),\n directory: 'DOCUMENTS',\n encoding: 'utf8',\n recursive: true\n });\n } catch (err) {\n console.error(`Capacitor failed to write collection ${collection}:`, err);\n }\n return;\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return;\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return;\n const dataDir = await companyFolder.getDirectoryHandle('data', { create: true });\n const fileHandle = await dataDir.getFileHandle(`${collection}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items || [], null, 2));\n await writable.close();\n } catch (err) {\n console.error(`Failed to write collection ${collection} to local directory:`, err);\n }\n }\n\n async writeDocumentFileToFolder(docId, name, dataUrl) {\n if (!this.folderSyncEnabled) return null;\n\n const ext = name.includes('.') ? name.split('.').pop() : 'bin';\n const fileName = `${docId}.${ext}`;\n const relativePath = `documents/${fileName}`;\n\n // Extract base64 payload\n const base64Parts = dataUrl.split(',');\n if (base64Parts.length < 2) return null;\n const base64Data = base64Parts[1];\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/documents/${fileName}`,\n data: base64Data,\n directory: 'DOCUMENTS',\n recursive: true\n });\n return relativePath;\n } catch (err) {\n console.error('Capacitor failed to write document:', err);\n return null;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return null;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return null;\n\n const mimeMatch = base64Parts[0].match(/:(.*?);/);\n const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';\n const binaryStr = atob(base64Data);\n const len = binaryStr.length;\n const bytes = new Uint8Array(len);\n for (let i = 0; i < len; i++) {\n bytes[i] = binaryStr.charCodeAt(i);\n }\n const blob = new Blob([bytes], { type: mime });\n\n const companyFolder = await this.ensureCompanyFolderHandle();\n if (!companyFolder) return null;\n const docsDir = await companyFolder.getDirectoryHandle('documents', { create: true });\n const fileHandle = await docsDir.getFileHandle(fileName, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(blob);\n await writable.close();\n\n return relativePath;\n } catch (err) {\n console.error('Failed to save document file to local directory:', err);\n return null;\n }\n }\n\n async getDocumentUrl(doc) {\n if (!doc || !doc.url) return '';\n if (doc.url.startsWith('data:') || doc.url.startsWith('http')) {\n return doc.url;\n }\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n const result = await Filesystem.readFile({\n path: `RelayDispatchData/${doc.url}`,\n directory: 'DOCUMENTS'\n });\n const mime = doc.type || 'application/octet-stream';\n return `data:${mime};base64,${result.data}`;\n } catch (err) {\n console.error('Capacitor failed to read document file:', err);\n throw err;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) {\n throw new Error('Local directory sync is not configured.');\n }\n\n try {\n const hasPerm = await this.verifyDirPermission(false);\n if (!hasPerm) {\n throw new Error('Permission to access the local directory was not granted.');\n }\n\n const parts = doc.url.split('/');\n const dirName = parts[0];\n const fileName = parts[1];\n\n const dir = await this.dirHandle.getDirectoryHandle(dirName);\n const fileHandle = await dir.getFileHandle(fileName);\n const file = await fileHandle.getFile();\n return URL.createObjectURL(file);\n } catch (err) {\n console.error('Failed to read file from local directory:', err);\n throw err;\n }\n }\n\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // Initialize IndexedDB for cloud users too, to store backup directory handles\n await this.initIndexedDB();\n\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id\n query = query.eq('company_id', this.companyId);\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed default user types if database is empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n // Unique channel name per subscribe — reusing a fixed name throws\n // \"cannot add postgres_changes callbacks ... after subscribe()\" when sync re-inits.\n const channel = supabase\n .channel(`table-db-changes-${this.companyId || 'anon'}-${Date.now()}`)\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n if (collection === 'contractors') {\n record.businessName = record.name;\n record.active = record.status === 'Active';\n }\n\n if (collection === 'suppliers') {\n record.active = record.status === 'Active';\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.license_number !== undefined) { record.licenseNumber = record.license_number; delete record.license_number; }\n if (record.hourly_rate !== undefined) { record.hourlyRate = parseFloat(record.hourly_rate); delete record.hourly_rate; }\n if (record.after_hours_rate !== undefined) { record.afterHoursRate = parseFloat(record.after_hours_rate); delete record.after_hours_rate; }\n if (record.callout_fee !== undefined) { record.calloutFee = parseFloat(record.callout_fee); delete record.callout_fee; }\n if (record.compliance_docs !== undefined) { record.complianceDocs = record.compliance_docs; delete record.compliance_docs; }\n if (record.account_number !== undefined) { record.accountNumber = record.account_number; delete record.account_number; }\n if (record.payment_terms !== undefined) { record.paymentTerms = record.payment_terms; delete record.payment_terms; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.day_offset !== undefined) { record.dayOffset = parseInt(record.day_offset); delete record.day_offset; }\n if (record.start_hour !== undefined) { record.startHour = parseFloat(record.start_hour); delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = parseFloat(record.end_hour); delete record.end_hour; }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (collection === 'contractors') {\n record.name = record.businessName;\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (collection === 'suppliers') {\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.licenseNumber !== undefined) { record.license_number = record.licenseNumber; delete record.licenseNumber; }\n if (record.hourlyRate !== undefined) { record.hourly_rate = record.hourlyRate; delete record.hourlyRate; }\n if (record.afterHoursRate !== undefined) { record.after_hours_rate = record.afterHoursRate; delete record.afterHoursRate; }\n if (record.calloutFee !== undefined) { record.callout_fee = record.calloutFee; delete record.calloutFee; }\n if (record.complianceDocs !== undefined) { record.compliance_docs = record.complianceDocs; delete record.complianceDocs; }\n if (record.accountNumber !== undefined) { record.account_number = record.accountNumber; delete record.accountNumber; }\n if (record.paymentTerms !== undefined) { record.payment_terms = record.paymentTerms; delete record.paymentTerms; }\n if (record.jobNumber !== undefined) { record.job_number = record.jobNumber; delete record.jobNumber; }\n if (record.dayOffset !== undefined) { record.day_offset = record.dayOffset; delete record.dayOffset; }\n if (record.startHour !== undefined) { record.start_hour = record.startHour; delete record.startHour; }\n if (record.endHour !== undefined) { record.end_hour = record.endHour; delete record.endHour; }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n const items = this.cache[collection] || [];\n if (collection === 'technicians') {\n const loginMode = typeof localStorage !== 'undefined' ? localStorage.getItem('relay_login_mode') : null;\n if (loginMode === 'local') {\n const currentUserStr = typeof localStorage !== 'undefined' ? localStorage.getItem('currentUser') : null;\n if (currentUserStr) {\n const currentUser = JSON.parse(currentUserStr);\n if (currentUser && currentUser.id) {\n return [{\n id: currentUser.id,\n name: currentUser.name || 'Local Admin',\n role: 'Administrator',\n color: currentUser.color || '#FF5C00',\n userTypeId: currentUser.userTypeId,\n email: currentUser.email || '',\n username: currentUser.username || ''\n }];\n }\n }\n }\n }\n return items;\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Surface a failed background write to the user instead of failing silently.\n // (A silent insert/update failure leaves the optimistic cache entry in place, so the\n // record looks saved until the next reload — the classic \"it didn't save\" symptom.)\n _notifyWriteError(action, collection, error) {\n console.error(`Error trying to ${action} ${collection}:`, error?.message || error, error);\n try {\n import('../components/Notifications.js')\n .then(({ showToast }) => showToast(\n `Couldn't ${action} ${collection.replace(/s$/, '')} — ${error?.message || 'database error'}`,\n 'error'))\n .catch(() => {});\n } catch (e) {}\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = this.getNextNumber('LD-', 'leads');\n }\n if (collection === 'notifications' && !item.number) {\n item.number = this.getNextNumber('NT-', 'notifications');\n }\n if (collection === 'invoices' && !item.number) {\n item.number = this.getNextNumber('INV-', 'invoices');\n }\n if (collection === 'quotes' && !item.number) {\n item.number = this.getNextNumber('Q-', 'quotes');\n }\n if (collection === 'jobs' && !item.number) {\n item.number = this.getNextNumber('J-', 'jobs');\n }\n if (collection === 'purchaseOrders' && !item.number) {\n item.number = this.getNextNumber('PO-', 'purchaseOrders');\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Local Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (this.folderSyncEnabled) {\n if (collection === 'documents' && item.url && item.url.startsWith('data:')) {\n this.writeDocumentFileToFolder(item.id, item.name, item.url).then(relPath => {\n if (relPath) {\n item.url = relPath;\n // Re-save in IndexedDB and rewrite folder collection with updated path\n this.writeRecordToIndexedDB(collection, item);\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n });\n } else {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n }\n this.writeRecordToIndexedDB(collection, item).catch(err => {\n console.error(`Error saving ${collection} to IndexedDB:`, err);\n });\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n item.email = email;\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n })\n });\n\n let data;\n const text = await res.text();\n try {\n data = text ? JSON.parse(text) : {};\n } catch (e) {\n throw new Error(`Server returned status ${res.status}. Response: ${text.substring(0, 150) || '[Empty]'}`);\n }\n if (!res.ok || !data.success) {\n throw new Error(data.error || `Failed to create user (Status ${res.status}).`);\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) {\n // Roll back the optimistic add so the UI matches the database\n this.cache[collection] = (this.cache[collection] || []).filter(x => x.id !== item.id);\n this.emit(collection, this.cache[collection]);\n this._notifyWriteError('save', collection, error);\n }\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n this.writeRecordToIndexedDB(collection, updated).catch(err => {\n console.error(`Error saving updated ${collection} to IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n })\n });\n\n let data;\n const text = await res.text();\n try {\n data = text ? JSON.parse(text) : {};\n } catch (e) {\n throw new Error(`Server returned status ${res.status}. Response: ${text.substring(0, 150) || '[Empty]'}`);\n }\n if (!res.ok || !data.success) {\n throw new Error(data.error || `Failed to update user (Status ${res.status}).`);\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Roll back to the previous version so the UI matches the database\n const arr = [...(this.cache[collection] || [])];\n const i = arr.findIndex(x => x.id === id);\n if (i !== -1) { arr[i] = previous; this.cache[collection] = arr; this.emit(collection, arr); }\n this._notifyWriteError('update', collection, error);\n }\n });\n }\n\n return updated;\n }\n\n delete(collection, id) {\n // 1. Update memory cache (keep a copy of the removed row for rollback)\n const removed = (this.cache[collection] || []).find(item => item.id === id);\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n this.deleteRecordFromIndexedDB(collection, id).catch(err => {\n console.error(`Error deleting ${id} from ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Restore the row we optimistically removed\n if (removed) { this.cache[collection] = [...(this.cache[collection] || []), removed]; this.emit(collection, this.cache[collection]); }\n this._notifyWriteError('delete', collection, error);\n }\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n /**\n * Generate the next sequential number for a collection.\n * Scans existing records, finds the highest numeric suffix, and returns prefix + (max+1) zero-padded to 5 digits.\n * e.g. getNextNumber('INV-', 'invoices') → 'INV-00001', 'INV-00002', ...\n */\n getNextNumber(prefix, collection) {\n const items = this.getAll(collection) || [];\n let maxNum = 0;\n\n items.forEach(item => {\n if (item.number && typeof item.number === 'string' && item.number.startsWith(prefix)) {\n const numStr = item.number.slice(prefix.length);\n const num = parseInt(numStr, 10);\n if (!isNaN(num) && num > maxNum) {\n maxNum = num;\n }\n }\n });\n\n return prefix + (maxNum + 1).toString().padStart(5, '0');\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: defaultLogoLarge,\n logoSmall: defaultLogoSmall,\n markupPercent: 20,\n taxEnabled: true,\n taxRate: 10,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default logos if user has not uploaded custom ones\n if (!merged.logo || merged.logo.includes('logo-large.png')) merged.logo = defaultLogoLarge;\n if (!merged.logoSmall) merged.logoSmall = defaultLogoSmall;\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n return merged;\n }\n\n return defaultSettings;\n }\n\n getTaxRate() {\n const settings = this.getSettings();\n if (settings.taxEnabled === false) return 0;\n const rate = settings.taxRate !== undefined ? Number(settings.taxRate) : 10;\n return rate / 100;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('settings'), JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId && !this.companyId.startsWith('acct_')) return true;\n return localStorage.getItem(this.getStorageKey('seeded')) === 'true';\n }\n\n save(collection, items) {\n const prev = this.cache[collection] || [];\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const p = this.writeAllToIndexedDB(collection, items).catch(err => {\n console.error(`Error saving all items to ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, items);\n }\n return p;\n }\n\n // Cloud mode: persist the whole collection. Upsert every current row, then delete\n // any rows that were removed from the array. (Previously this only touched the\n // in-memory cache, so every save() — assign-tech, role edits, form templates,\n // portal edits, stock receiving, mark-read, etc. — silently failed to persist.)\n const table = TABLE_MAP[collection];\n if (!table) return; // cache-only collections (e.g. 'activity') have no table\n\n const payload = items\n .filter(it => it && it.id)\n .map(it => this.denormalizeRecord({ ...it, companyId: this.companyId }, collection));\n if (payload.length) {\n supabase.from(table).upsert(payload).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n\n const currentIds = new Set(items.map(it => it && it.id));\n const removedIds = prev.filter(p => p && p.id && !currentIds.has(p.id)).map(p => p.id);\n if (removedIds.length) {\n supabase.from(table).delete().in('id', removedIds).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n }\n\n markSeeded() {\n localStorage.setItem(this.getStorageKey('seeded'), 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('formTemplates', templates);\n return;\n }\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n async seedDefaultUserTypes() {\n if (!this.companyId) return;\n\n const userTypes = [\n {\n id: `${this.companyId}_ut_admin`,\n name: 'Admin',\n description: 'Full system access',\n permissions: buildGranularPerms(() => true)\n },\n {\n id: `${this.companyId}_ut_manager`,\n name: 'Manager',\n description: 'Can manage most workflows but limited settings access',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company', 'manage_tax'].includes(key);\n return true;\n })\n },\n {\n id: `${this.companyId}_ut_tech`,\n name: 'Technician',\n description: 'Field staff — limited to their own jobs, schedule and timesheets',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n {\n id: `${this.companyId}_ut_office`,\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders', 'Suppliers'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n\n this.cache.userTypes = userTypes;\n this.emit('userTypes', userTypes);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('userTypes', userTypes);\n return;\n }\n\n const dbPayload = userTypes.map(ut => this.denormalizeRecord({ ...ut, companyId: this.companyId }, 'userTypes'));\n const { error } = await supabase.from('user_types').insert(dbPayload);\n if (error) console.error('Error seeding default user types:', error);\n }\n\n async seedDefaultTechnicians() {\n const companyId = this.companyId;\n if (!companyId) return;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const managerTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_manager` : 'ut_manager';\n const techTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_tech` : 'ut_tech';\n const officeTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_office` : 'ut_office';\n\n const defaultTechs = [\n { id: `${companyId}_tech_1`, name: 'Jake Morrow', role: 'Senior Electrician', color: '#3B82F6', userTypeId: adminTypeId, payRate: 95.00, email: 'jake@apexpowerservices.com.au', phone: '0412 233 445', username: 'jake', password: '123456' },\n { id: `${companyId}_tech_2`, name: 'Ryan Holt', role: 'Service Manager', color: '#10B981', userTypeId: managerTypeId, payRate: 85.00, email: 'ryan@apexpowerservices.com.au', phone: '0423 344 556', username: 'ryan', password: '123456' },\n { id: `${companyId}_tech_3`, name: 'Sandra Okafor', role: 'Electrician', color: '#8B5CF6', userTypeId: techTypeId, payRate: 80.00, email: 'sandra@apexpowerservices.com.au', phone: '0434 455 667', username: 'sandra', password: '123456' },\n { id: `${companyId}_tech_4`, name: 'Dean Caruso', role: 'Office Administrator',color: '#F59E0B', userTypeId: officeTypeId, payRate: 50.00, email: 'dean@apexpowerservices.com.au', phone: '0445 566 778', username: 'dean', password: '123456' }\n ];\n\n this.cache.technicians = defaultTechs;\n this.emit('technicians', defaultTechs);\n\n if (companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('technicians', defaultTechs);\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder('technicians', defaultTechs).catch(err => {\n console.error('Error writing seeded technicians to local folder:', err);\n });\n }\n return;\n }\n\n const dbPayload = defaultTechs.map(t => this.denormalizeRecord({ ...t, companyId }, 'technicians'));\n const { error } = await supabase.from('technicians').insert(dbPayload);\n if (error) console.error('Error seeding default technicians:', error);\n }\n\n async migrateLocalToCloud(companyId, adminUserId) {\n this.companyId = companyId;\n this.userId = adminUserId;\n\n // 1. Update the remote company settings with local companySettings\n const currentSettings = this.getSettings();\n const { error: compErr } = await supabase.from('companies').update({\n abn: currentSettings.abn || '',\n phone: currentSettings.phone || '',\n domain: currentSettings.domain || '',\n email: currentSettings.email || '',\n address: currentSettings.address || '',\n settings: currentSettings\n }).eq('id', companyId);\n if (compErr) console.error('Error updating cloud company settings during migration:', compErr);\n\n // 2. Loop through all collections and push to Supabase\n // Skip 'companies' and 'technicians'\n const collectionsToMigrate = Object.keys(TABLE_MAP).filter(col => col !== 'companies' && col !== 'technicians');\n\n for (const col of collectionsToMigrate) {\n const items = this.cache[col] || [];\n if (items.length === 0) continue;\n\n const table = TABLE_MAP[col];\n if (!table) continue;\n\n // Assign the new cloud company_id and denormalize\n const payload = items.map(item => {\n const itemCopy = { ...item, companyId };\n return this.denormalizeRecord(itemCopy, col);\n });\n\n // Write to Supabase using upsert\n const { error } = await supabase.from(table).upsert(payload);\n if (error) {\n console.error(`Error migrating collection ${col}:`, error);\n throw new Error(`Failed to migrate ${col} data: ${error.message}`);\n }\n }\n\n // 3. Clear/close local DB connection and sync from Cloud\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n }\n\n deleteLocalAccountData(accountId) {\n return new Promise((resolve) => {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n if (typeof localStorage !== 'undefined') {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem(`relay_${accountId}_${col}`);\n });\n localStorage.removeItem(`relay_${accountId}_folder_sync_enabled`);\n localStorage.removeItem(`relay_${accountId}_seeded`);\n localStorage.removeItem(`relay_${accountId}_settings`);\n }\n if (typeof window !== 'undefined' && window.indexedDB) {\n const req = window.indexedDB.deleteDatabase(`RelayDispatchDB_${accountId}`);\n req.onsuccess = () => resolve();\n req.onerror = () => resolve();\n req.onblocked = () => {\n console.warn('Database deletion blocked, resolving anyway');\n resolve();\n };\n } else {\n resolve();\n }\n });\n }\n\n async clearAll() {\n this.clearSync();\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n \n if (activeAccount) {\n await this.deleteLocalAccountData(activeAccount);\n } else {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n this.dirHandle = null;\n try {\n await this.clearAllIndexedDB();\n } catch (err) {\n console.error('Error clearing IndexedDB:', err);\n }\n }\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "3875d9aed63c32df22c0dea6475d5f31122475ef": {"t": 1785754193.340045, "n": 82309, "h": "44124a1147da1c98", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\nconst defaultLogoLarge = new URL('../assets/logo-large.png', import.meta.url).href;\nconst defaultLogoSmall = new URL('../assets/logo-small.png', import.meta.url).href;\n\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents',\n leads: 'leads',\n schedule: 'schedule'\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"license_number\",\n \"hourly_rate\",\n \"after_hours_rate\",\n \"callout_fee\",\n \"specialties\",\n \"notes\",\n \"portal_token\",\n \"compliance_docs\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"address\",\n \"category\",\n \"account_number\",\n \"payment_terms\",\n \"notes\",\n \"attachments\",\n \"created_at\",\n \"updated_at\"\n ],\n leads: [\n \"id\",\n \"company_id\",\n \"number\",\n \"title\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"status\",\n \"source\",\n \"value\",\n \"description\",\n \"priority\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"type\",\n \"description\",\n \"priority\",\n \"read\",\n \"number\",\n \"asset_id\",\n \"job_id\",\n \"due_date\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g><text x=\"90\" y=\"36\" fill=\"#2D3134\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"25\" font-weight=\"900\" letter-spacing=\"0.04em\">DISPATCH</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nconst MODULE_PERMS = {\n 'Dashboard': [{ key: 'view' }],\n 'Customers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_contacts' }],\n 'Leads': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'convert' }],\n 'Quotes': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'approve' }, { key: 'convert' }, { key: 'generate_pdf' }],\n 'Jobs': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }, { key: 'manage_tasks' }, { key: 'book_time' }, { key: 'view_costs' }, { key: 'view_quotes_tab' }, { key: 'view_pos_tab' }, { key: 'view_timesheets_tab' }, { key: 'view_invoices_tab' }, { key: 'manage_materials' }, { key: 'create_invoice' }],\n 'Timesheets': [{ key: 'view_own' }, { key: 'view' }, { key: 'create' }, { key: 'approve' }, { key: 'edit_all' }, { key: 'export' }],\n 'Assets': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Schedule': [{ key: 'view_own' }, { key: 'view' }, { key: 'edit' }],\n 'Contractors': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }],\n 'Suppliers': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Stock': [{ key: 'view' }, { key: 'create' }, { key: 'edit' }, { key: 'delete' }],\n 'Purchase Orders': [{ key: 'view' }, { key: 'create' }, { key: 'approve' }],\n 'Invoices': [{ key: 'view' }, { key: 'create' }, { key: 'send' }, { key: 'void' }],\n 'Reports': [{ key: 'view' }, { key: 'export' }],\n 'Documents': [{ key: 'view' }, { key: 'upload' }],\n 'Settings': [{ key: 'view' }, { key: 'edit_company' }, { key: 'manage_users' }, { key: 'manage_tax' }]\n};\n\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n this.db = null;\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n\n // Check if we have a cloud user at boot (before filling cache)\n const bootUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n const isCloudUser = !!(bootUser && bootUser.companyId && !bootUser.companyId.startsWith('acct_'));\n\n if (!isCloudUser && bootUser && bootUser.companyId && typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', bootUser.companyId);\n }\n\n // Pre-initialize empty arrays for collections.\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Load local settings if they exist (only for offline/demo mode)\n if (!isCloudUser && typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId && !this.companyId.startsWith('acct_')) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n this.clearSync();\n }\n });\n\n // Auto-trigger sync or local load if user already logged in at boot\n if (isCloudUser) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n this.initPromise = this.initializeCloudSync();\n } else {\n if (bootUser && bootUser.companyId) {\n this.userId = bootUser.id;\n this.companyId = bootUser.companyId;\n }\n this.initPromise = this.initializeLocalStore();\n }\n }\n\n getStorageKey(collection) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (activeAccount) {\n return `relay_${activeAccount}_${collection}`;\n }\n return `simpro_${collection}`;\n }\n\n getDBName() {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n return activeAccount ? `RelayDispatchDB_${activeAccount}` : 'RelayDispatchDB';\n }\n\n async initializeUser(user) {\n if (user && user.companyId && !user.companyId.startsWith('acct_')) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n } else {\n this.clearSync();\n if (user && user.companyId) {\n this.userId = user.id;\n this.companyId = user.companyId;\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.setItem('relay_active_account', user.companyId);\n }\n } else {\n if (typeof sessionStorage !== 'undefined') {\n sessionStorage.removeItem('relay_active_account');\n }\n }\n this.initPromise = this.initializeLocalStore();\n await this.initPromise;\n }\n }\n\n async initializeLocalStore() {\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.dirHandle = null;\n this.folderSyncEnabled = typeof localStorage !== 'undefined'\n ? localStorage.getItem(this.getStorageKey('folder_sync_enabled')) === 'true'\n : false;\n this.folderSyncPermissionGranted = false;\n\n try {\n await this.initIndexedDB();\n } catch (e) {\n console.error('Failed to initialize IndexedDB, falling back to empty arrays:', e);\n }\n\n // Load local settings if they exist (namespaced)\n this.companySettings = null;\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem(this.getStorageKey('settings'));\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n this.emit('settings', this.getSettings());\n\n // Seed default user types and forms if empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n if (this.cache.technicians.length === 0) {\n await this.seedDefaultTechnicians();\n }\n }\n\n initIndexedDB() {\n return new Promise((resolve, reject) => {\n if (typeof window === 'undefined' || !window.indexedDB) {\n return resolve();\n }\n\n const dbName = this.getDBName();\n const request = window.indexedDB.open(dbName, 2);\n\n request.onerror = (e) => {\n console.error('IndexedDB open error:', e.target.error);\n reject(e.target.error);\n };\n\n request.onsuccess = async (e) => {\n this.db = e.target.result;\n \n // 1. Load custom directory sync config from IndexedDB config store if it exists\n try {\n if (this.db.objectStoreNames.contains('config')) {\n await new Promise((resConfig) => {\n const transaction = this.db.transaction('config', 'readonly');\n const configStore = transaction.objectStore('config');\n const getReq = configStore.get('directory_handle');\n getReq.onsuccess = () => {\n if (getReq.result) {\n this.dirHandle = getReq.result.value;\n }\n resConfig();\n };\n getReq.onerror = () => resConfig();\n });\n }\n } catch (err) {\n console.warn('Failed to read config from IndexedDB:', err);\n }\n\n // Verify directory permission silently on boot if handle exists\n if (this.dirHandle) {\n try {\n // Check permission silently without prompt dialog\n const opts = { mode: 'readwrite' };\n const status = await this.dirHandle.queryPermission(opts);\n this.folderSyncPermissionGranted = status === 'granted';\n } catch (err) {\n console.warn('Failed to query directory permission on boot:', err);\n }\n }\n\n // Load all collections into cache\n const collections = Object.keys(TABLE_MAP);\n const loadPromises = collections.map(async (col) => {\n try {\n this.cache[col] = await this.readAllFromIndexedDB(col);\n } catch (err) {\n console.error(`Error loading collection ${col} from IndexedDB:`, err);\n this.cache[col] = [];\n }\n });\n\n await Promise.all(loadPromises);\n\n // Perform migration if we have old data in localStorage (only if not in a namespaced local account profile)\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n if (!activeAccount) {\n const migratePromises = collections.map(async (col) => {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n const items = JSON.parse(localData);\n if (Array.isArray(items) && items.length > 0) {\n // Write all items to IndexedDB\n await this.writeAllToIndexedDB(col, items);\n // Merge into cache if cache is empty\n if (this.cache[col].length === 0) {\n this.cache[col] = items;\n }\n }\n localStorage.removeItem('simpro_' + col);\n } catch (err) {\n console.error(`Migration error for ${col}:`, err);\n }\n }\n });\n\n await Promise.all(migratePromises);\n }\n resolve();\n };\n\n request.onupgradeneeded = (e) => {\n const db = e.target.result;\n const collections = Object.keys(TABLE_MAP);\n collections.forEach(col => {\n if (!db.objectStoreNames.contains(col)) {\n db.createObjectStore(col, { keyPath: 'id' });\n }\n });\n if (!db.objectStoreNames.contains('config')) {\n db.createObjectStore('config', { keyPath: 'key' });\n }\n };\n });\n }\n\n readAllFromIndexedDB(storeName) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve([]);\n try {\n const transaction = this.db.transaction(storeName, 'readonly');\n const store = transaction.objectStore(storeName);\n const getAllRequest = store.getAll();\n\n getAllRequest.onsuccess = () => {\n resolve(getAllRequest.result || []);\n };\n\n getAllRequest.onerror = (e) => {\n reject(e.target.error);\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeRecordToIndexedDB(storeName, record) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const putRequest = store.put(record);\n\n putRequest.onsuccess = () => resolve();\n putRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n deleteRecordFromIndexedDB(storeName, id) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n const deleteRequest = store.delete(id);\n\n deleteRequest.onsuccess = () => resolve();\n deleteRequest.onerror = (e) => reject(e.target.error);\n } catch (err) {\n reject(err);\n }\n });\n }\n\n writeAllToIndexedDB(storeName, items) {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const transaction = this.db.transaction(storeName, 'readwrite');\n const store = transaction.objectStore(storeName);\n \n // Clear existing\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n \n clearRequest.onsuccess = () => {\n let count = 0;\n if (items.length === 0) return resolve();\n \n items.forEach(item => {\n const putRequest = store.put(item);\n putRequest.onerror = (e) => reject(e.target.error);\n putRequest.onsuccess = () => {\n count++;\n if (count === items.length) {\n resolve();\n }\n };\n });\n };\n } catch (err) {\n reject(err);\n }\n });\n }\n\n clearAllIndexedDB() {\n return new Promise((resolve, reject) => {\n if (!this.db) return resolve();\n try {\n const collections = Object.keys(TABLE_MAP);\n const transaction = this.db.transaction(collections, 'readwrite');\n let count = 0;\n \n collections.forEach(col => {\n const store = transaction.objectStore(col);\n const clearRequest = store.clear();\n clearRequest.onerror = (e) => reject(e.target.error);\n clearRequest.onsuccess = () => {\n count++;\n if (count === collections.length) {\n resolve();\n }\n };\n });\n } catch (err) {\n reject(err);\n }\n });\n }\n\n // ── Local Folder Sync / File System Access Driver ──────────────────────────\n\n async verifyDirPermission(readWrite) {\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n this.folderSyncPermissionGranted = true;\n return true; // Capacitor native has permissions checked at app level\n }\n\n if (!this.dirHandle) return false;\n const opts = {};\n if (readWrite) opts.mode = 'readwrite';\n \n try {\n const currentPerm = await this.dirHandle.queryPermission(opts);\n if (currentPerm === 'granted') {\n this.folderSyncPermissionGranted = true;\n return true;\n }\n \n // Request permission (must be called within a user gesture handler)\n const newPerm = await this.dirHandle.requestPermission(opts);\n this.folderSyncPermissionGranted = newPerm === 'granted';\n return this.folderSyncPermissionGranted;\n } catch (e) {\n this.folderSyncPermissionGranted = false;\n return false;\n }\n }\n\n async setLocalDirectory(dirHandle) {\n this.dirHandle = dirHandle;\n if (dirHandle) {\n this.folderSyncEnabled = true;\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('folder_sync_enabled'), 'true');\n }\n \n // Save handle to IndexedDB\n if (this.db) {\n await new Promise((resolve, reject) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const putReq = configStore.put({ key: 'directory_handle', value: dirHandle });\n putReq.onsuccess = () => resolve();\n putReq.onerror = (e) => reject(e.target.error);\n });\n }\n \n this.folderSyncPermissionGranted = true;\n \n // Force rewrite all cached collections to directory immediately\n const collections = Object.keys(TABLE_MAP);\n await Promise.all(collections.map(col => this.writeCollectionToFolder(col, this.cache[col])));\n } else {\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n \n // Remove handle from IndexedDB\n if (this.db) {\n await new Promise((resolve) => {\n const transaction = this.db.transaction('config', 'readwrite');\n const configStore = transaction.objectStore('config');\n const deleteReq = configStore.delete('directory_handle');\n deleteReq.onsuccess = () => resolve();\n deleteReq.onerror = () => resolve();\n });\n }\n }\n }\n\n async writeCollectionToFolder(collection, items) {\n if (!this.folderSyncEnabled) return;\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/data/${collection}.json`,\n data: JSON.stringify(items || [], null, 2),\n directory: 'DOCUMENTS',\n encoding: 'utf8',\n recursive: true\n });\n } catch (err) {\n console.error(`Capacitor failed to write collection ${collection}:`, err);\n }\n return;\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return;\n\n const dataDir = await this.dirHandle.getDirectoryHandle('data', { create: true });\n const fileHandle = await dataDir.getFileHandle(`${collection}.json`, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(JSON.stringify(items || [], null, 2));\n await writable.close();\n } catch (err) {\n console.error(`Failed to write collection ${collection} to local directory:`, err);\n }\n }\n\n async writeDocumentFileToFolder(docId, name, dataUrl) {\n if (!this.folderSyncEnabled) return null;\n\n const ext = name.includes('.') ? name.split('.').pop() : 'bin';\n const fileName = `${docId}.${ext}`;\n const relativePath = `documents/${fileName}`;\n\n // Extract base64 payload\n const base64Parts = dataUrl.split(',');\n if (base64Parts.length < 2) return null;\n const base64Data = base64Parts[1];\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n await Filesystem.writeFile({\n path: `RelayDispatchData/documents/${fileName}`,\n data: base64Data,\n directory: 'DOCUMENTS',\n recursive: true\n });\n return relativePath;\n } catch (err) {\n console.error('Capacitor failed to write document:', err);\n return null;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) return null;\n try {\n const hasPerm = await this.verifyDirPermission(true);\n if (!hasPerm) return null;\n\n const mimeMatch = base64Parts[0].match(/:(.*?);/);\n const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';\n const binaryStr = atob(base64Data);\n const len = binaryStr.length;\n const bytes = new Uint8Array(len);\n for (let i = 0; i < len; i++) {\n bytes[i] = binaryStr.charCodeAt(i);\n }\n const blob = new Blob([bytes], { type: mime });\n\n const docsDir = await this.dirHandle.getDirectoryHandle('documents', { create: true });\n const fileHandle = await docsDir.getFileHandle(fileName, { create: true });\n const writable = await fileHandle.createWritable();\n await writable.write(blob);\n await writable.close();\n\n return relativePath;\n } catch (err) {\n console.error('Failed to save document file to local directory:', err);\n return null;\n }\n }\n\n async getDocumentUrl(doc) {\n if (!doc || !doc.url) return '';\n if (doc.url.startsWith('data:') || doc.url.startsWith('http')) {\n return doc.url;\n }\n\n // 1. Capacitor Native environment\n if (typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {\n try {\n const Filesystem = window.Capacitor.Plugins.Filesystem;\n const result = await Filesystem.readFile({\n path: `RelayDispatchData/${doc.url}`,\n directory: 'DOCUMENTS'\n });\n const mime = doc.type || 'application/octet-stream';\n return `data:${mime};base64,${result.data}`;\n } catch (err) {\n console.error('Capacitor failed to read document file:', err);\n throw err;\n }\n }\n\n // 2. Standard Browser File System Access API\n if (!this.dirHandle) {\n throw new Error('Local directory sync is not configured.');\n }\n\n try {\n const hasPerm = await this.verifyDirPermission(false);\n if (!hasPerm) {\n throw new Error('Permission to access the local directory was not granted.');\n }\n\n const parts = doc.url.split('/');\n const dirName = parts[0];\n const fileName = parts[1];\n\n const dir = await this.dirHandle.getDirectoryHandle(dirName);\n const fileHandle = await dir.getFileHandle(fileName);\n const file = await fileHandle.getFile();\n return URL.createObjectURL(file);\n } catch (err) {\n console.error('Failed to read file from local directory:', err);\n throw err;\n }\n }\n\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id\n query = query.eq('company_id', this.companyId);\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed default user types if database is empty\n if (this.cache.userTypes.length === 0) {\n await this.seedDefaultUserTypes();\n }\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n // Unique channel name per subscribe — reusing a fixed name throws\n // \"cannot add postgres_changes callbacks ... after subscribe()\" when sync re-inits.\n const channel = supabase\n .channel(`table-db-changes-${this.companyId || 'anon'}-${Date.now()}`)\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n if (collection === 'contractors') {\n record.businessName = record.name;\n record.active = record.status === 'Active';\n }\n\n if (collection === 'suppliers') {\n record.active = record.status === 'Active';\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.license_number !== undefined) { record.licenseNumber = record.license_number; delete record.license_number; }\n if (record.hourly_rate !== undefined) { record.hourlyRate = parseFloat(record.hourly_rate); delete record.hourly_rate; }\n if (record.after_hours_rate !== undefined) { record.afterHoursRate = parseFloat(record.after_hours_rate); delete record.after_hours_rate; }\n if (record.callout_fee !== undefined) { record.calloutFee = parseFloat(record.callout_fee); delete record.callout_fee; }\n if (record.compliance_docs !== undefined) { record.complianceDocs = record.compliance_docs; delete record.compliance_docs; }\n if (record.account_number !== undefined) { record.accountNumber = record.account_number; delete record.account_number; }\n if (record.payment_terms !== undefined) { record.paymentTerms = record.payment_terms; delete record.payment_terms; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.day_offset !== undefined) { record.dayOffset = parseInt(record.day_offset); delete record.day_offset; }\n if (record.start_hour !== undefined) { record.startHour = parseFloat(record.start_hour); delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = parseFloat(record.end_hour); delete record.end_hour; }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (collection === 'contractors') {\n record.name = record.businessName;\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (collection === 'suppliers') {\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.licenseNumber !== undefined) { record.license_number = record.licenseNumber; delete record.licenseNumber; }\n if (record.hourlyRate !== undefined) { record.hourly_rate = record.hourlyRate; delete record.hourlyRate; }\n if (record.afterHoursRate !== undefined) { record.after_hours_rate = record.afterHoursRate; delete record.afterHoursRate; }\n if (record.calloutFee !== undefined) { record.callout_fee = record.calloutFee; delete record.calloutFee; }\n if (record.complianceDocs !== undefined) { record.compliance_docs = record.complianceDocs; delete record.complianceDocs; }\n if (record.accountNumber !== undefined) { record.account_number = record.accountNumber; delete record.accountNumber; }\n if (record.paymentTerms !== undefined) { record.payment_terms = record.paymentTerms; delete record.paymentTerms; }\n if (record.jobNumber !== undefined) { record.job_number = record.jobNumber; delete record.jobNumber; }\n if (record.dayOffset !== undefined) { record.day_offset = record.dayOffset; delete record.dayOffset; }\n if (record.startHour !== undefined) { record.start_hour = record.startHour; delete record.startHour; }\n if (record.endHour !== undefined) { record.end_hour = record.endHour; delete record.endHour; }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n return this.cache[collection] || [];\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Surface a failed background write to the user instead of failing silently.\n // (A silent insert/update failure leaves the optimistic cache entry in place, so the\n // record looks saved until the next reload — the classic \"it didn't save\" symptom.)\n _notifyWriteError(action, collection, error) {\n console.error(`Error trying to ${action} ${collection}:`, error?.message || error, error);\n try {\n import('../components/Notifications.js')\n .then(({ showToast }) => showToast(\n `Couldn't ${action} ${collection.replace(/s$/, '')} — ${error?.message || 'database error'}`,\n 'error'))\n .catch(() => {});\n } catch (e) {}\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = 'LD-' + Date.now().toString().slice(-5);\n }\n if (collection === 'notifications' && !item.number) {\n item.number = 'NT-' + Date.now().toString().slice(-5);\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Local Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (this.folderSyncEnabled) {\n if (collection === 'documents' && item.url && item.url.startsWith('data:')) {\n this.writeDocumentFileToFolder(item.id, item.name, item.url).then(relPath => {\n if (relPath) {\n item.url = relPath;\n // Re-save in IndexedDB and rewrite folder collection with updated path\n this.writeRecordToIndexedDB(collection, item);\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n });\n } else {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n }\n this.writeRecordToIndexedDB(collection, item).catch(err => {\n console.error(`Error saving ${collection} to IndexedDB:`, err);\n });\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n item.email = email;\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n })\n });\n\n let data;\n const text = await res.text();\n try {\n data = text ? JSON.parse(text) : {};\n } catch (e) {\n throw new Error(`Server returned status ${res.status}. Response: ${text.substring(0, 150) || '[Empty]'}`);\n }\n if (!res.ok || !data.success) {\n throw new Error(data.error || `Failed to create user (Status ${res.status}).`);\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) {\n // Roll back the optimistic add so the UI matches the database\n this.cache[collection] = (this.cache[collection] || []).filter(x => x.id !== item.id);\n this.emit(collection, this.cache[collection]);\n this._notifyWriteError('save', collection, error);\n }\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n this.writeRecordToIndexedDB(collection, updated).catch(err => {\n console.error(`Error saving updated ${collection} to IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n })\n });\n\n let data;\n const text = await res.text();\n try {\n data = text ? JSON.parse(text) : {};\n } catch (e) {\n throw new Error(`Server returned status ${res.status}. Response: ${text.substring(0, 150) || '[Empty]'}`);\n }\n if (!res.ok || !data.success) {\n throw new Error(data.error || `Failed to update user (Status ${res.status}).`);\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Roll back to the previous version so the UI matches the database\n const arr = [...(this.cache[collection] || [])];\n const i = arr.findIndex(x => x.id === id);\n if (i !== -1) { arr[i] = previous; this.cache[collection] = arr; this.emit(collection, arr); }\n this._notifyWriteError('update', collection, error);\n }\n });\n }\n\n return updated;\n }\n\n delete(collection, id) {\n // 1. Update memory cache (keep a copy of the removed row for rollback)\n const removed = (this.cache[collection] || []).find(item => item.id === id);\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n this.deleteRecordFromIndexedDB(collection, id).catch(err => {\n console.error(`Error deleting ${id} from ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, this.cache[collection]);\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Restore the row we optimistically removed\n if (removed) { this.cache[collection] = [...(this.cache[collection] || []), removed]; this.emit(collection, this.cache[collection]); }\n this._notifyWriteError('delete', collection, error);\n }\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: defaultLogoLarge,\n logoSmall: defaultLogoSmall,\n markupPercent: 20,\n taxEnabled: true,\n taxRate: 10,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default logos if user has not uploaded custom ones\n if (!merged.logo) merged.logo = defaultLogoLarge;\n if (!merged.logoSmall) merged.logoSmall = defaultLogoSmall;\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n return merged;\n }\n\n return defaultSettings;\n }\n\n getTaxRate() {\n const settings = this.getSettings();\n if (settings.taxEnabled === false) return 0;\n const rate = settings.taxRate !== undefined ? Number(settings.taxRate) : 10;\n return rate / 100;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem(this.getStorageKey('settings'), JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId && !this.companyId.startsWith('acct_')) return true;\n return localStorage.getItem(this.getStorageKey('seeded')) === 'true';\n }\n\n save(collection, items) {\n const prev = this.cache[collection] || [];\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to IndexedDB / Folder Sync\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n this.writeAllToIndexedDB(collection, items).catch(err => {\n console.error(`Error saving all items to ${collection} in IndexedDB:`, err);\n });\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder(collection, items);\n }\n return;\n }\n\n // Cloud mode: persist the whole collection. Upsert every current row, then delete\n // any rows that were removed from the array. (Previously this only touched the\n // in-memory cache, so every save() — assign-tech, role edits, form templates,\n // portal edits, stock receiving, mark-read, etc. — silently failed to persist.)\n const table = TABLE_MAP[collection];\n if (!table) return; // cache-only collections (e.g. 'activity') have no table\n\n const payload = items\n .filter(it => it && it.id)\n .map(it => this.denormalizeRecord({ ...it, companyId: this.companyId }, collection));\n if (payload.length) {\n supabase.from(table).upsert(payload).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n\n const currentIds = new Set(items.map(it => it && it.id));\n const removedIds = prev.filter(p => p && p.id && !currentIds.has(p.id)).map(p => p.id);\n if (removedIds.length) {\n supabase.from(table).delete().in('id', removedIds).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n }\n\n markSeeded() {\n localStorage.setItem(this.getStorageKey('seeded'), 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('formTemplates', templates);\n return;\n }\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n async seedDefaultUserTypes() {\n if (!this.companyId) return;\n\n const userTypes = [\n {\n id: `${this.companyId}_ut_admin`,\n name: 'Admin',\n description: 'Full system access',\n permissions: buildGranularPerms(() => true)\n },\n {\n id: `${this.companyId}_ut_manager`,\n name: 'Manager',\n description: 'Can manage most workflows but limited settings access',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company', 'manage_tax'].includes(key);\n return true;\n })\n },\n {\n id: `${this.companyId}_ut_tech`,\n name: 'Technician',\n description: 'Field staff — limited to their own jobs, schedule and timesheets',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n {\n id: `${this.companyId}_ut_office`,\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders', 'Suppliers'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n\n this.cache.userTypes = userTypes;\n this.emit('userTypes', userTypes);\n\n if (this.companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('userTypes', userTypes);\n return;\n }\n\n const dbPayload = userTypes.map(ut => this.denormalizeRecord({ ...ut, companyId: this.companyId }, 'userTypes'));\n const { error } = await supabase.from('user_types').insert(dbPayload);\n if (error) console.error('Error seeding default user types:', error);\n }\n\n async seedDefaultTechnicians() {\n const companyId = this.companyId;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const techTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_tech` : 'ut_tech';\n\n const defaultTechs = [\n { id: `${companyId}_tech_1`, name: 'Jake Morrow', role: 'Senior Electrician', color: '#3B82F6', userTypeId: adminTypeId, payRate: 95.00, email: 'jake@apexpowerservices.com.au', phone: '0412 233 445', username: 'jake', password: '123456' },\n { id: `${companyId}_tech_2`, name: 'Ryan Holt', role: 'Electrician', color: '#10B981', userTypeId: techTypeId, payRate: 80.00, email: 'ryan@apexpowerservices.com.au', phone: '0423 344 556', username: 'ryan', password: '123456' },\n { id: `${companyId}_tech_3`, name: 'Sandra Okafor', role: 'Electrician', color: '#8B5CF6', userTypeId: techTypeId, payRate: 80.00, email: 'sandra@apexpowerservices.com.au', phone: '0434 455 667', username: 'sandra', password: '123456' },\n { id: `${companyId}_tech_4`, name: 'Dean Caruso', role: 'Apprentice', color: '#F59E0B', userTypeId: techTypeId, payRate: 45.00, email: 'dean@apexpowerservices.com.au', phone: '0445 566 778', username: 'dean', password: '123456' }\n ];\n\n this.cache.technicians = defaultTechs;\n this.emit('technicians', defaultTechs);\n\n if (companyId.startsWith('acct_')) {\n await this.writeAllToIndexedDB('technicians', defaultTechs);\n if (this.folderSyncEnabled) {\n this.writeCollectionToFolder('technicians', defaultTechs).catch(err => {\n console.error('Error writing seeded technicians to local folder:', err);\n });\n }\n return;\n }\n\n const dbPayload = defaultTechs.map(t => this.denormalizeRecord({ ...t, companyId }, 'technicians'));\n const { error } = await supabase.from('technicians').insert(dbPayload);\n if (error) console.error('Error seeding default technicians:', error);\n }\n\n async migrateLocalToCloud(companyId, adminUserId) {\n this.companyId = companyId;\n this.userId = adminUserId;\n\n // 1. Update the remote company settings with local companySettings\n const currentSettings = this.getSettings();\n const { error: compErr } = await supabase.from('companies').update({\n abn: currentSettings.abn || '',\n phone: currentSettings.phone || '',\n domain: currentSettings.domain || '',\n email: currentSettings.email || '',\n address: currentSettings.address || '',\n settings: currentSettings\n }).eq('id', companyId);\n if (compErr) console.error('Error updating cloud company settings during migration:', compErr);\n\n // 2. Loop through all collections and push to Supabase\n // Skip 'companies' and 'technicians'\n const collectionsToMigrate = Object.keys(TABLE_MAP).filter(col => col !== 'companies' && col !== 'technicians');\n\n for (const col of collectionsToMigrate) {\n const items = this.cache[col] || [];\n if (items.length === 0) continue;\n\n const table = TABLE_MAP[col];\n if (!table) continue;\n\n // Assign the new cloud company_id and denormalize\n const payload = items.map(item => {\n const itemCopy = { ...item, companyId };\n return this.denormalizeRecord(itemCopy, col);\n });\n\n // Write to Supabase using upsert\n const { error } = await supabase.from(table).upsert(payload);\n if (error) {\n console.error(`Error migrating collection ${col}:`, error);\n throw new Error(`Failed to migrate ${col} data: ${error.message}`);\n }\n }\n\n // 3. Clear/close local DB connection and sync from Cloud\n if (this.db) {\n this.db.close();\n this.db = null;\n }\n this.initPromise = this.initializeCloudSync();\n await this.initPromise;\n }\n\n deleteLocalAccountData(accountId) {\n if (typeof localStorage !== 'undefined') {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem(`relay_${accountId}_${col}`);\n });\n localStorage.removeItem(`relay_${accountId}_folder_sync_enabled`);\n localStorage.removeItem(`relay_${accountId}_seeded`);\n localStorage.removeItem(`relay_${accountId}_settings`);\n }\n if (typeof window !== 'undefined' && window.indexedDB) {\n window.indexedDB.deleteDatabase(`RelayDispatchDB_${accountId}`);\n }\n }\n\n clearAll() {\n this.clearSync();\n if (!this.companyId || this.companyId.startsWith('acct_')) {\n const activeAccount = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n \n if (activeAccount) {\n this.deleteLocalAccountData(activeAccount);\n } else {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n if (typeof localStorage !== 'undefined') {\n localStorage.removeItem(this.getStorageKey('folder_sync_enabled'));\n }\n this.folderSyncEnabled = false;\n this.folderSyncPermissionGranted = false;\n this.dirHandle = null;\n this.clearAllIndexedDB().catch(err => {\n console.error('Error clearing IndexedDB:', err);\n });\n }\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "9c677e1c88a72554725371c27b6c06142ddd7fed": {"t": 1785754194.8961458, "n": 53170, "h": "3996c4060180a1db", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents',\n leads: 'leads',\n schedule: 'schedule'\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"license_number\",\n \"hourly_rate\",\n \"after_hours_rate\",\n \"callout_fee\",\n \"specialties\",\n \"notes\",\n \"portal_token\",\n \"compliance_docs\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"address\",\n \"category\",\n \"account_number\",\n \"payment_terms\",\n \"notes\",\n \"attachments\",\n \"created_at\",\n \"updated_at\"\n ],\n leads: [\n \"id\",\n \"company_id\",\n \"number\",\n \"title\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"status\",\n \"source\",\n \"value\",\n \"description\",\n \"priority\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"type\",\n \"description\",\n \"priority\",\n \"read\",\n \"number\",\n \"asset_id\",\n \"job_id\",\n \"due_date\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g><text x=\"90\" y=\"35\" fill=\"#FF5C00\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"800\" letter-spacing=\"0.05em\">RELAY</text><text x=\"180\" y=\"35\" fill=\"#8A99AD\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"500\" letter-spacing=\"0.03em\">— Dispatch</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n\n // Pre-initialize empty arrays for collections or load from localStorage in local mode\n Object.keys(TABLE_MAP).forEach(col => {\n if (typeof localStorage !== 'undefined') {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n this.cache[col] = JSON.parse(localData);\n return;\n } catch (e) {\n console.error(`Error parsing localStorage for ${col}:`, e);\n }\n }\n }\n this.cache[col] = [];\n });\n\n // Load local settings if they exist\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem('simpro_settings');\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n this.clearSync();\n }\n });\n\n // Auto-trigger sync if user already logged in at boot\n const currentUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n if (currentUser && currentUser.companyId) {\n this.userId = currentUser.id;\n this.companyId = currentUser.companyId;\n this.initPromise = this.initializeCloudSync();\n }\n }\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id (except for profiles where we fetch tenant users)\n if (table !== 'profiles') {\n query = query.eq('company_id', this.companyId);\n }\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n // Unique channel name per subscribe — reusing a fixed name throws\n // \"cannot add postgres_changes callbacks ... after subscribe()\" when sync re-inits.\n const channel = supabase\n .channel(`table-db-changes-${this.companyId || 'anon'}-${Date.now()}`)\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n if (collection === 'contractors') {\n record.businessName = record.name;\n record.active = record.status === 'Active';\n }\n\n if (collection === 'suppliers') {\n record.active = record.status === 'Active';\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.license_number !== undefined) { record.licenseNumber = record.license_number; delete record.license_number; }\n if (record.hourly_rate !== undefined) { record.hourlyRate = parseFloat(record.hourly_rate); delete record.hourly_rate; }\n if (record.after_hours_rate !== undefined) { record.afterHoursRate = parseFloat(record.after_hours_rate); delete record.after_hours_rate; }\n if (record.callout_fee !== undefined) { record.calloutFee = parseFloat(record.callout_fee); delete record.callout_fee; }\n if (record.compliance_docs !== undefined) { record.complianceDocs = record.compliance_docs; delete record.compliance_docs; }\n if (record.account_number !== undefined) { record.accountNumber = record.account_number; delete record.account_number; }\n if (record.payment_terms !== undefined) { record.paymentTerms = record.payment_terms; delete record.payment_terms; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.day_offset !== undefined) { record.dayOffset = parseInt(record.day_offset); delete record.day_offset; }\n if (record.start_hour !== undefined) { record.startHour = parseFloat(record.start_hour); delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = parseFloat(record.end_hour); delete record.end_hour; }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (collection === 'contractors') {\n record.name = record.businessName;\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (collection === 'suppliers') {\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.licenseNumber !== undefined) { record.license_number = record.licenseNumber; delete record.licenseNumber; }\n if (record.hourlyRate !== undefined) { record.hourly_rate = record.hourlyRate; delete record.hourlyRate; }\n if (record.afterHoursRate !== undefined) { record.after_hours_rate = record.afterHoursRate; delete record.afterHoursRate; }\n if (record.calloutFee !== undefined) { record.callout_fee = record.calloutFee; delete record.calloutFee; }\n if (record.complianceDocs !== undefined) { record.compliance_docs = record.complianceDocs; delete record.complianceDocs; }\n if (record.accountNumber !== undefined) { record.account_number = record.accountNumber; delete record.accountNumber; }\n if (record.paymentTerms !== undefined) { record.payment_terms = record.paymentTerms; delete record.paymentTerms; }\n if (record.jobNumber !== undefined) { record.job_number = record.jobNumber; delete record.jobNumber; }\n if (record.dayOffset !== undefined) { record.day_offset = record.dayOffset; delete record.dayOffset; }\n if (record.startHour !== undefined) { record.start_hour = record.startHour; delete record.startHour; }\n if (record.endHour !== undefined) { record.end_hour = record.endHour; delete record.endHour; }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n return this.cache[collection] || [];\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Surface a failed background write to the user instead of failing silently.\n // (A silent insert/update failure leaves the optimistic cache entry in place, so the\n // record looks saved until the next reload — the classic \"it didn't save\" symptom.)\n _notifyWriteError(action, collection, error) {\n console.error(`Error trying to ${action} ${collection}:`, error?.message || error, error);\n try {\n import('../components/Notifications.js')\n .then(({ showToast }) => showToast(\n `Couldn't ${action} ${collection.replace(/s$/, '')} — ${error?.message || 'database error'}`,\n 'error'))\n .catch(() => {});\n } catch (e) {}\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = 'LD-' + Date.now().toString().slice(-5);\n }\n if (collection === 'notifications' && !item.number) {\n item.number = 'NT-' + Date.now().toString().slice(-5);\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to localStorage\n if (!this.companyId) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n item.email = email;\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) {\n // Roll back the optimistic add so the UI matches the database\n this.cache[collection] = (this.cache[collection] || []).filter(x => x.id !== item.id);\n this.emit(collection, this.cache[collection]);\n this._notifyWriteError('save', collection, error);\n }\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to localStorage\n if (!this.companyId) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to update user.');\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Roll back to the previous version so the UI matches the database\n const arr = [...(this.cache[collection] || [])];\n const i = arr.findIndex(x => x.id === id);\n if (i !== -1) { arr[i] = previous; this.cache[collection] = arr; this.emit(collection, arr); }\n this._notifyWriteError('update', collection, error);\n }\n });\n }\n\n return updated;\n }\n\n delete(collection, id) {\n // 1. Update memory cache (keep a copy of the removed row for rollback)\n const removed = (this.cache[collection] || []).find(item => item.id === id);\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to localStorage\n if (!this.companyId) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Restore the row we optimistically removed\n if (removed) { this.cache[collection] = [...(this.cache[collection] || []), removed]; this.emit(collection, this.cache[collection]); }\n this._notifyWriteError('delete', collection, error);\n }\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: RELAY_LOGO_SVG,\n logoSmall: RELAY_LOGO_SMALL_SVG,\n markupPercent: 20,\n taxEnabled: true,\n taxRate: 10,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default RELAY logos if user has not uploaded custom ones\n if (!merged.logo) merged.logo = RELAY_LOGO_SVG;\n if (!merged.logoSmall) merged.logoSmall = RELAY_LOGO_SMALL_SVG;\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n return merged;\n }\n return defaultSettings;\n }\n\n getTaxRate() {\n const settings = this.getSettings();\n if (settings.taxEnabled === false) return 0;\n const rate = settings.taxRate !== undefined ? Number(settings.taxRate) : 10;\n return rate / 100;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_settings', JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId) return true;\n return localStorage.getItem('simpro__seeded') === 'true';\n }\n\n save(collection, items) {\n const prev = this.cache[collection] || [];\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to localStorage to support the offline demo users list\n if (!this.companyId) {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n return;\n }\n\n // Cloud mode: persist the whole collection. Upsert every current row, then delete\n // any rows that were removed from the array. (Previously this only touched the\n // in-memory cache, so every save() — assign-tech, role edits, form templates,\n // portal edits, stock receiving, mark-read, etc. — silently failed to persist.)\n const table = TABLE_MAP[collection];\n if (!table) return; // cache-only collections (e.g. 'activity') have no table\n\n const payload = items\n .filter(it => it && it.id)\n .map(it => this.denormalizeRecord({ ...it, companyId: this.companyId }, collection));\n if (payload.length) {\n supabase.from(table).upsert(payload).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n\n const currentIds = new Set(items.map(it => it && it.id));\n const removedIds = prev.filter(p => p && p.id && !currentIds.has(p.id)).map(p => p.id);\n if (removedIds.length) {\n supabase.from(table).delete().in('id', removedIds).then(({ error }) => {\n if (error) this._notifyWriteError('save', collection, error);\n });\n }\n }\n\n markSeeded() {\n localStorage.setItem('simpro__seeded', 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n clearAll() {\n this.clearSync();\n if (!this.companyId) {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "0cedec2631a6e0e708b088f88e76f55c12a93280": {"t": 1785754196.4768803, "n": 51853, "h": "5f10fa43e14aa0ff", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents',\n leads: 'leads',\n schedule: 'schedule'\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"license_number\",\n \"hourly_rate\",\n \"after_hours_rate\",\n \"callout_fee\",\n \"specialties\",\n \"notes\",\n \"portal_token\",\n \"compliance_docs\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"contact_name\",\n \"address\",\n \"category\",\n \"account_number\",\n \"payment_terms\",\n \"notes\",\n \"attachments\",\n \"created_at\",\n \"updated_at\"\n ],\n leads: [\n \"id\",\n \"company_id\",\n \"number\",\n \"title\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"status\",\n \"source\",\n \"value\",\n \"description\",\n \"priority\",\n \"created_at\",\n \"updated_at\"\n ],\n schedule: [\n \"id\",\n \"company_id\",\n \"job_id\",\n \"job_number\",\n \"title\",\n \"technician_id\",\n \"technician_name\",\n \"color\",\n \"day_offset\",\n \"start_hour\",\n \"end_hour\",\n \"customer_name\",\n \"site_address\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g><text x=\"90\" y=\"35\" fill=\"#FF5C00\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"800\" letter-spacing=\"0.05em\">RELAY</text><text x=\"180\" y=\"35\" fill=\"#8A99AD\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"500\" letter-spacing=\"0.03em\">— Dispatch</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n\n // Pre-initialize empty arrays for collections or load from localStorage in local mode\n Object.keys(TABLE_MAP).forEach(col => {\n if (typeof localStorage !== 'undefined') {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n this.cache[col] = JSON.parse(localData);\n return;\n } catch (e) {\n console.error(`Error parsing localStorage for ${col}:`, e);\n }\n }\n }\n this.cache[col] = [];\n });\n\n // Load local settings if they exist\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem('simpro_settings');\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n this.clearSync();\n }\n });\n\n // Auto-trigger sync if user already logged in at boot\n const currentUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n if (currentUser && currentUser.companyId) {\n this.userId = currentUser.id;\n this.companyId = currentUser.companyId;\n this.initPromise = this.initializeCloudSync();\n }\n }\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id (except for profiles where we fetch tenant users)\n if (table !== 'profiles') {\n query = query.eq('company_id', this.companyId);\n }\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n // Unique channel name per subscribe — reusing a fixed name throws\n // \"cannot add postgres_changes callbacks ... after subscribe()\" when sync re-inits.\n const channel = supabase\n .channel(`table-db-changes-${this.companyId || 'anon'}-${Date.now()}`)\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n if (collection === 'contractors') {\n record.businessName = record.name;\n record.active = record.status === 'Active';\n }\n\n if (collection === 'suppliers') {\n record.active = record.status === 'Active';\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.license_number !== undefined) { record.licenseNumber = record.license_number; delete record.license_number; }\n if (record.hourly_rate !== undefined) { record.hourlyRate = parseFloat(record.hourly_rate); delete record.hourly_rate; }\n if (record.after_hours_rate !== undefined) { record.afterHoursRate = parseFloat(record.after_hours_rate); delete record.after_hours_rate; }\n if (record.callout_fee !== undefined) { record.calloutFee = parseFloat(record.callout_fee); delete record.callout_fee; }\n if (record.compliance_docs !== undefined) { record.complianceDocs = record.compliance_docs; delete record.compliance_docs; }\n if (record.account_number !== undefined) { record.accountNumber = record.account_number; delete record.account_number; }\n if (record.payment_terms !== undefined) { record.paymentTerms = record.payment_terms; delete record.payment_terms; }\n if (record.job_number !== undefined) { record.jobNumber = record.job_number; delete record.job_number; }\n if (record.day_offset !== undefined) { record.dayOffset = parseInt(record.day_offset); delete record.day_offset; }\n if (record.start_hour !== undefined) { record.startHour = parseFloat(record.start_hour); delete record.start_hour; }\n if (record.end_hour !== undefined) { record.endHour = parseFloat(record.end_hour); delete record.end_hour; }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (collection === 'contractors') {\n record.name = record.businessName;\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (collection === 'suppliers') {\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n // Contractor / supplier rich fields + lead / schedule fields\n if (record.licenseNumber !== undefined) { record.license_number = record.licenseNumber; delete record.licenseNumber; }\n if (record.hourlyRate !== undefined) { record.hourly_rate = record.hourlyRate; delete record.hourlyRate; }\n if (record.afterHoursRate !== undefined) { record.after_hours_rate = record.afterHoursRate; delete record.afterHoursRate; }\n if (record.calloutFee !== undefined) { record.callout_fee = record.calloutFee; delete record.calloutFee; }\n if (record.complianceDocs !== undefined) { record.compliance_docs = record.complianceDocs; delete record.complianceDocs; }\n if (record.accountNumber !== undefined) { record.account_number = record.accountNumber; delete record.accountNumber; }\n if (record.paymentTerms !== undefined) { record.payment_terms = record.paymentTerms; delete record.paymentTerms; }\n if (record.jobNumber !== undefined) { record.job_number = record.jobNumber; delete record.jobNumber; }\n if (record.dayOffset !== undefined) { record.day_offset = record.dayOffset; delete record.dayOffset; }\n if (record.startHour !== undefined) { record.start_hour = record.startHour; delete record.startHour; }\n if (record.endHour !== undefined) { record.end_hour = record.endHour; delete record.endHour; }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n return this.cache[collection] || [];\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Surface a failed background write to the user instead of failing silently.\n // (A silent insert/update failure leaves the optimistic cache entry in place, so the\n // record looks saved until the next reload — the classic \"it didn't save\" symptom.)\n _notifyWriteError(action, collection, error) {\n console.error(`Error trying to ${action} ${collection}:`, error?.message || error, error);\n try {\n import('../components/Notifications.js')\n .then(({ showToast }) => showToast(\n `Couldn't ${action} ${collection.replace(/s$/, '')} — ${error?.message || 'database error'}`,\n 'error'))\n .catch(() => {});\n } catch (e) {}\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = 'LD-' + Date.now().toString().slice(-5);\n }\n if (collection === 'notifications' && !item.number) {\n item.number = 'NT-' + Date.now().toString().slice(-5);\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to localStorage\n if (!this.companyId) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n item.email = email;\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) {\n // Roll back the optimistic add so the UI matches the database\n this.cache[collection] = (this.cache[collection] || []).filter(x => x.id !== item.id);\n this.emit(collection, this.cache[collection]);\n this._notifyWriteError('save', collection, error);\n }\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to localStorage\n if (!this.companyId) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to update user.');\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Roll back to the previous version so the UI matches the database\n const arr = [...(this.cache[collection] || [])];\n const i = arr.findIndex(x => x.id === id);\n if (i !== -1) { arr[i] = previous; this.cache[collection] = arr; this.emit(collection, arr); }\n this._notifyWriteError('update', collection, error);\n }\n });\n }\n\n return updated;\n }\n\n delete(collection, id) {\n // 1. Update memory cache (keep a copy of the removed row for rollback)\n const removed = (this.cache[collection] || []).find(item => item.id === id);\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to localStorage\n if (!this.companyId) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) {\n // Restore the row we optimistically removed\n if (removed) { this.cache[collection] = [...(this.cache[collection] || []), removed]; this.emit(collection, this.cache[collection]); }\n this._notifyWriteError('delete', collection, error);\n }\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: RELAY_LOGO_SVG,\n logoSmall: RELAY_LOGO_SMALL_SVG,\n markupPercent: 20,\n taxEnabled: true,\n taxRate: 10,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default RELAY logos if user has not uploaded custom ones\n if (!merged.logo) merged.logo = RELAY_LOGO_SVG;\n if (!merged.logoSmall) merged.logoSmall = RELAY_LOGO_SMALL_SVG;\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n return merged;\n }\n return defaultSettings;\n }\n\n getTaxRate() {\n const settings = this.getSettings();\n if (settings.taxEnabled === false) return 0;\n const rate = settings.taxRate !== undefined ? Number(settings.taxRate) : 10;\n return rate / 100;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_settings', JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId) return true;\n return localStorage.getItem('simpro__seeded') === 'true';\n }\n\n save(collection, items) {\n this.cache[collection] = items;\n this.emit(collection, items);\n \n // If not running in cloud mode, fall back to localStorage to support the offline demo users list\n if (!this.companyId) {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n }\n\n markSeeded() {\n localStorage.setItem('simpro__seeded', 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n clearAll() {\n this.clearSync();\n if (!this.companyId) {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "c99b5ba6ab286248e8d7cea7a82446caa0e9f148": {"t": 1785754198.1448345, "n": 46194, "h": "8a68bdd8903d749c", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents'\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g><text x=\"90\" y=\"35\" fill=\"#FF5C00\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"800\" letter-spacing=\"0.05em\">RELAY</text><text x=\"180\" y=\"35\" fill=\"#8A99AD\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"500\" letter-spacing=\"0.03em\">— Dispatch</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n\n // Pre-initialize empty arrays for collections or load from localStorage in local mode\n Object.keys(TABLE_MAP).forEach(col => {\n if (typeof localStorage !== 'undefined') {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n this.cache[col] = JSON.parse(localData);\n return;\n } catch (e) {\n console.error(`Error parsing localStorage for ${col}:`, e);\n }\n }\n }\n this.cache[col] = [];\n });\n\n // Load local settings if they exist\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem('simpro_settings');\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n this.clearSync();\n }\n });\n\n // Auto-trigger sync if user already logged in at boot\n const currentUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n if (currentUser && currentUser.companyId) {\n this.userId = currentUser.id;\n this.companyId = currentUser.companyId;\n this.initPromise = this.initializeCloudSync();\n }\n }\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id (except for profiles where we fetch tenant users)\n if (table !== 'profiles') {\n query = query.eq('company_id', this.companyId);\n }\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n const channel = supabase\n .channel('table-db-changes')\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n if (collection === 'contractors') {\n record.businessName = record.name;\n record.active = record.status === 'Active';\n }\n\n if (collection === 'suppliers') {\n record.active = record.status === 'Active';\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (collection === 'contractors') {\n record.name = record.businessName;\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (collection === 'suppliers') {\n record.status = record.active ? 'Active' : 'Inactive';\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n return this.cache[collection] || [];\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = 'LD-' + Date.now().toString().slice(-5);\n }\n if (collection === 'notifications' && !item.number) {\n item.number = 'NT-' + Date.now().toString().slice(-5);\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to localStorage\n if (!this.companyId) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n item.email = email;\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) console.error(`Error saving new record to ${table}:`, error);\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to localStorage\n if (!this.companyId) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to update user.');\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) console.error(`Error updating record in ${table}:`, error);\n });\n }\n\n return updated;\n }\n\n delete(collection, id) {\n // 1. Update memory cache\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to localStorage\n if (!this.companyId) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) console.error(`Error deleting record from ${table}:`, error);\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: RELAY_LOGO_SVG,\n logoSmall: RELAY_LOGO_SMALL_SVG,\n markupPercent: 20,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default RELAY logos if user has not uploaded custom ones\n if (!merged.logo) merged.logo = RELAY_LOGO_SVG;\n if (!merged.logoSmall) merged.logoSmall = RELAY_LOGO_SMALL_SVG;\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n return merged;\n }\n return defaultSettings;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_settings', JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId) return true;\n return localStorage.getItem('simpro__seeded') === 'true';\n }\n\n save(collection, items) {\n this.cache[collection] = items;\n this.emit(collection, items);\n \n // If not running in cloud mode, fall back to localStorage to support the offline demo users list\n if (!this.companyId) {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n }\n\n markSeeded() {\n localStorage.setItem('simpro__seeded', 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n clearAll() {\n this.clearSync();\n if (!this.companyId) {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "c78b666ec2eb0c2c89b6f3420970bf94d71dc484": {"t": 1785754199.7118819, "n": 45706, "h": "00fae70b4039fe82", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents'\n};\n\nconst TABLE_COLUMNS = {\n companies: [\n \"id\",\n \"name\",\n \"abn\",\n \"phone\",\n \"domain\",\n \"email\",\n \"address\",\n \"settings\",\n \"created_at\",\n \"updated_at\"\n ],\n profiles: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"username\",\n \"phone\",\n \"role\",\n \"user_type_id\",\n \"color\",\n \"pay_rate\",\n \"force_password_change\",\n \"created_at\",\n \"updated_at\"\n ],\n user_types: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"permissions\",\n \"created_at\",\n \"updated_at\"\n ],\n customers: [\n \"id\",\n \"company_id\",\n \"company\",\n \"first_name\",\n \"last_name\",\n \"email\",\n \"phone\",\n \"address\",\n \"status\",\n \"type\",\n \"portal_token\",\n \"created_at\",\n \"updated_at\"\n ],\n assets: [\n \"id\",\n \"company_id\",\n \"name\",\n \"type\",\n \"serial\",\n \"owner_type\",\n \"customer_id\",\n \"customer_name\",\n \"current_meter\",\n \"recovery_rate\",\n \"status\",\n \"logs\",\n \"created_at\",\n \"updated_at\"\n ],\n maintenance_plans: [\n \"id\",\n \"company_id\",\n \"name\",\n \"asset_id\",\n \"trigger_type\",\n \"meter_interval\",\n \"last_triggered_meter\",\n \"next_service_date\",\n \"status\",\n \"priority\",\n \"frequency\",\n \"collision_merging\",\n \"created_at\",\n \"updated_at\"\n ],\n task_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"description\",\n \"tags\",\n \"tasks\",\n \"created_at\",\n \"updated_at\"\n ],\n quotes: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"valid_until\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n jobs: [\n \"id\",\n \"company_id\",\n \"number\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"site_address\",\n \"title\",\n \"type\",\n \"status\",\n \"priority\",\n \"technician_id\",\n \"technician_name\",\n \"quote_id\",\n \"asset_id\",\n \"scheduled_date\",\n \"estimated_hours\",\n \"labor_cost\",\n \"material_cost\",\n \"tasks\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n invoices: [\n \"id\",\n \"company_id\",\n \"number\",\n \"job_id\",\n \"customer_id\",\n \"customer_name\",\n \"contact_name\",\n \"title\",\n \"status\",\n \"line_items\",\n \"subtotal\",\n \"tax\",\n \"total\",\n \"due_date\",\n \"notes\",\n \"created_at\",\n \"updated_at\"\n ],\n stock: [\n \"id\",\n \"company_id\",\n \"name\",\n \"category\",\n \"unit\",\n \"cost_price\",\n \"unit_price\",\n \"reorder_level\",\n \"quantity\",\n \"locations\",\n \"supplier\",\n \"created_at\",\n \"updated_at\"\n ],\n timesheets: [\n \"id\",\n \"company_id\",\n \"technician_id\",\n \"technician_name\",\n \"date\",\n \"duration_hours\",\n \"job_id\",\n \"status\",\n \"approved_by\",\n \"created_at\",\n \"updated_at\"\n ],\n contractors: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"created_at\",\n \"updated_at\"\n ],\n suppliers: [\n \"id\",\n \"company_id\",\n \"name\",\n \"email\",\n \"phone\",\n \"status\",\n \"created_at\",\n \"updated_at\"\n ],\n purchase_orders: [\n \"id\",\n \"company_id\",\n \"number\",\n \"supplier_id\",\n \"supplier_name\",\n \"status\",\n \"line_items\",\n \"total\",\n \"created_at\",\n \"updated_at\"\n ],\n notifications: [\n \"id\",\n \"company_id\",\n \"title\",\n \"message\",\n \"link\",\n \"status\",\n \"created_at\",\n \"updated_at\"\n ],\n form_templates: [\n \"id\",\n \"company_id\",\n \"name\",\n \"fields\",\n \"created_at\",\n \"updated_at\"\n ],\n form_instances: [\n \"id\",\n \"company_id\",\n \"template_id\",\n \"job_id\",\n \"values\",\n \"created_at\",\n \"updated_at\"\n ],\n kits: [\n \"id\",\n \"company_id\",\n \"name\",\n \"items\",\n \"created_at\",\n \"updated_at\"\n ],\n documents: [\n \"id\",\n \"company_id\",\n \"name\",\n \"file_path\",\n \"job_id\",\n \"created_at\",\n \"updated_at\"\n ]\n};\n\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g><text x=\"90\" y=\"35\" fill=\"#FF5C00\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"800\" letter-spacing=\"0.05em\">RELAY</text><text x=\"180\" y=\"35\" fill=\"#8A99AD\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"500\" letter-spacing=\"0.03em\">— Dispatch</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n\n // Pre-initialize empty arrays for collections or load from localStorage in local mode\n Object.keys(TABLE_MAP).forEach(col => {\n if (typeof localStorage !== 'undefined') {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n this.cache[col] = JSON.parse(localData);\n return;\n } catch (e) {\n console.error(`Error parsing localStorage for ${col}:`, e);\n }\n }\n }\n this.cache[col] = [];\n });\n\n // Load local settings if they exist\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem('simpro_settings');\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n this.clearSync();\n }\n });\n\n // Auto-trigger sync if user already logged in at boot\n const currentUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n if (currentUser && currentUser.companyId) {\n this.userId = currentUser.id;\n this.companyId = currentUser.companyId;\n this.initPromise = this.initializeCloudSync();\n }\n }\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id (except for profiles where we fetch tenant users)\n if (table !== 'profiles') {\n query = query.eq('company_id', this.companyId);\n }\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n const channel = supabase\n .channel('table-db-changes')\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.sections = record.fields?.sections || [];\n record.description = record.fields?.description || '';\n delete record.fields;\n }\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item, collection) {\n const record = { ...item };\n\n if (collection === 'formTemplates') {\n record.fields = {\n description: record.description || '',\n sections: record.sections || []\n };\n delete record.description;\n delete record.sections;\n }\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n\n // Filter out columns not in schema to prevent 400 Bad Request\n const table = TABLE_MAP[collection];\n if (table && TABLE_COLUMNS[table]) {\n const allowedCols = TABLE_COLUMNS[table];\n Object.keys(record).forEach(key => {\n if (!allowedCols.includes(key)) {\n delete record[key];\n }\n });\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n return this.cache[collection] || [];\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = 'LD-' + Date.now().toString().slice(-5);\n }\n if (collection === 'notifications' && !item.number) {\n item.number = 'NT-' + Date.now().toString().slice(-5);\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to localStorage\n if (!this.companyId) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n item.email = email;\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) console.error(`Error saving new record to ${table}:`, error);\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to localStorage\n if (!this.companyId) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to update user.');\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated, collection);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) console.error(`Error updating record in ${table}:`, error);\n });\n }\n\n return updated;\n }\n\n delete(collection, id) {\n // 1. Update memory cache\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to localStorage\n if (!this.companyId) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) console.error(`Error deleting record from ${table}:`, error);\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: RELAY_LOGO_SVG,\n logoSmall: RELAY_LOGO_SMALL_SVG,\n markupPercent: 20,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default RELAY logos if user has not uploaded custom ones\n if (!merged.logo) merged.logo = RELAY_LOGO_SVG;\n if (!merged.logoSmall) merged.logoSmall = RELAY_LOGO_SMALL_SVG;\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n return merged;\n }\n return defaultSettings;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_settings', JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId) return true;\n return localStorage.getItem('simpro__seeded') === 'true';\n }\n\n save(collection, items) {\n this.cache[collection] = items;\n this.emit(collection, items);\n \n // If not running in cloud mode, fall back to localStorage to support the offline demo users list\n if (!this.companyId) {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n }\n\n markSeeded() {\n localStorage.setItem('simpro__seeded', 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t, 'formTemplates'));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n clearAll() {\n this.clearSync();\n if (!this.companyId) {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "0c52f04309f7a18102037c772d88520e92073155": {"t": 1785754201.9739184, "n": 40967, "h": "a98f7f4a4dc3c097", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents'\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g><text x=\"90\" y=\"35\" fill=\"#FF5C00\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"800\" letter-spacing=\"0.05em\">RELAY</text><text x=\"180\" y=\"35\" fill=\"#8A99AD\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"500\" letter-spacing=\"0.03em\">— Dispatch</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n\n // Pre-initialize empty arrays for collections or load from localStorage in local mode\n Object.keys(TABLE_MAP).forEach(col => {\n if (typeof localStorage !== 'undefined') {\n const localData = localStorage.getItem('simpro_' + col);\n if (localData) {\n try {\n this.cache[col] = JSON.parse(localData);\n return;\n } catch (e) {\n console.error(`Error parsing localStorage for ${col}:`, e);\n }\n }\n }\n this.cache[col] = [];\n });\n\n // Load local settings if they exist\n if (typeof localStorage !== 'undefined') {\n const localSettings = localStorage.getItem('simpro_settings');\n if (localSettings) {\n try {\n this.companySettings = JSON.parse(localSettings);\n } catch {}\n }\n }\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n this.clearSync();\n }\n });\n\n // Auto-trigger sync if user already logged in at boot\n const currentUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n if (currentUser && currentUser.companyId) {\n this.userId = currentUser.id;\n this.companyId = currentUser.companyId;\n this.initPromise = this.initializeCloudSync();\n }\n }\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id (except for profiles where we fetch tenant users)\n if (table !== 'profiles') {\n query = query.eq('company_id', this.companyId);\n }\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n const channel = supabase\n .channel('table-db-changes')\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item) {\n const record = { ...item };\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n return this.cache[collection] || [];\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = 'LD-' + Date.now().toString().slice(-5);\n }\n if (collection === 'notifications' && !item.number) {\n item.number = 'NT-' + Date.now().toString().slice(-5);\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n if (this.companyId) {\n item.companyId = this.companyId;\n }\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to localStorage\n if (!this.companyId) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n return item;\n }\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n item.email = email;\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) console.error(`Error saving new record to ${table}:`, error);\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // If not running in cloud mode, perform local cache update and write to localStorage\n if (!this.companyId) {\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n return updated;\n }\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to update user.');\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) console.error(`Error updating record in ${table}:`, error);\n });\n }\n\n return updated;\n }\n\n delete(collection, id) {\n // 1. Update memory cache\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // If not running in cloud mode, fall back to localStorage\n if (!this.companyId) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n return;\n }\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) console.error(`Error deleting record from ${table}:`, error);\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: RELAY_LOGO_SVG,\n logoSmall: RELAY_LOGO_SMALL_SVG,\n markupPercent: 20,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default RELAY logos if user has not uploaded custom ones\n if (!merged.logo) merged.logo = RELAY_LOGO_SVG;\n if (!merged.logoSmall) merged.logoSmall = RELAY_LOGO_SMALL_SVG;\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n return merged;\n }\n return defaultSettings;\n }\n\n saveSettings(settings) {\n this.companySettings = settings;\n this.emit('settings', settings);\n\n if (!this.companyId) {\n if (typeof localStorage !== 'undefined') {\n localStorage.setItem('simpro_settings', JSON.stringify(settings));\n }\n return;\n }\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId)\n .then(({ error }) => {\n if (error) console.error('Error saving company settings to Supabase:', error);\n });\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId) return true;\n return localStorage.getItem('simpro__seeded') === 'true';\n }\n\n save(collection, items) {\n this.cache[collection] = items;\n this.emit(collection, items);\n \n // If not running in cloud mode, fall back to localStorage to support the offline demo users list\n if (!this.companyId) {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n }\n\n markSeeded() {\n localStorage.setItem('simpro__seeded', 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n clearAll() {\n this.clearSync();\n if (!this.companyId) {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "c31634420eeede6fd9e6d31539593ee7cc172dd0": {"t": 1785754203.574325, "n": 39127, "h": "b098aa088483db34", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents'\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g><text x=\"90\" y=\"35\" fill=\"#FF5C00\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"800\" letter-spacing=\"0.05em\">RELAY</text><text x=\"180\" y=\"35\" fill=\"#8A99AD\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"500\" letter-spacing=\"0.03em\">— Dispatch</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n\n // Pre-initialize empty arrays for collections to avoid undefined maps on startup\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n this.clearSync();\n }\n });\n\n // Auto-trigger sync if user already logged in at boot\n const currentUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n if (currentUser && currentUser.companyId) {\n this.userId = currentUser.id;\n this.companyId = currentUser.companyId;\n this.initPromise = this.initializeCloudSync();\n }\n }\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id (except for profiles where we fetch tenant users)\n if (table !== 'profiles') {\n query = query.eq('company_id', this.companyId);\n }\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n const channel = supabase\n .channel('table-db-changes')\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item) {\n const record = { ...item };\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n return this.cache[collection] || [];\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n create(collection, item) {\n if (!this.companyId) return item;\n\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = 'LD-' + Date.now().toString().slice(-5);\n }\n if (collection === 'notifications' && !item.number) {\n item.number = 'NT-' + Date.now().toString().slice(-5);\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n item.companyId = this.companyId;\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n item.email = email;\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n })();\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase.from(table).insert([dbPayload]).then(({ error }) => {\n if (error) console.error(`Error saving new record to ${table}:`, error);\n });\n }\n\n return item;\n }\n\n update(collection, id, updates) {\n if (!this.companyId) return null;\n\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n return (async () => {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to update user.');\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.id === id);\n if (idx !== -1) {\n cachedItems[idx] = updated;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n return updated;\n } catch (err) {\n throw err;\n }\n })();\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated);\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id)\n .then(({ error }) => {\n if (error) console.error(`Error updating record in ${table}:`, error);\n });\n }\n\n return updated;\n }\n\n delete(collection, id) {\n if (!this.companyId) return;\n\n // 1. Update memory cache\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n supabase\n .from(table)\n .delete()\n .eq('id', id)\n .then(({ error }) => {\n if (error) console.error(`Error deleting record from ${table}:`, error);\n });\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: RELAY_LOGO_SVG,\n logoSmall: RELAY_LOGO_SMALL_SVG,\n markupPercent: 20,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default RELAY logos if user has not uploaded custom ones\n if (!merged.logo) merged.logo = RELAY_LOGO_SVG;\n if (!merged.logoSmall) merged.logoSmall = RELAY_LOGO_SMALL_SVG;\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n return merged;\n }\n return defaultSettings;\n }\n\n async saveSettings(settings) {\n if (!this.companyId) return;\n\n this.companySettings = settings;\n this.emit('settings', settings);\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n const { error } = await supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId);\n\n if (error) {\n console.error('Error saving company settings to Supabase:', error);\n throw error;\n }\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId) return true;\n return localStorage.getItem('simpro__seeded') === 'true';\n }\n\n save(collection, items) {\n this.cache[collection] = items;\n this.emit(collection, items);\n \n // If not running in cloud mode, fall back to localStorage to support the offline demo users list\n if (!this.companyId) {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n }\n\n markSeeded() {\n localStorage.setItem('simpro__seeded', 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n clearAll() {\n this.clearSync();\n if (!this.companyId) {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "66e6f1493939dfef7293b734d066895b286c3f48": {"t": 1785754205.1992564, "n": 38428, "h": "05635b253893d096", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents'\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g><text x=\"90\" y=\"35\" fill=\"#FF5C00\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"800\" letter-spacing=\"0.05em\">RELAY</text><text x=\"180\" y=\"35\" fill=\"#8A99AD\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"500\" letter-spacing=\"0.03em\">— Dispatch</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n\n // Pre-initialize empty arrays for collections to avoid undefined maps on startup\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n this.clearSync();\n }\n });\n\n // Auto-trigger sync if user already logged in at boot\n const currentUser = typeof localStorage !== 'undefined'\n ? JSON.parse(localStorage.getItem('currentUser') || 'null')\n : null;\n if (currentUser && currentUser.companyId) {\n this.userId = currentUser.id;\n this.companyId = currentUser.companyId;\n this.initPromise = this.initializeCloudSync();\n }\n }\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id (except for profiles where we fetch tenant users)\n if (table !== 'profiles') {\n query = query.eq('company_id', this.companyId);\n }\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n const channel = supabase\n .channel('table-db-changes')\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item) {\n const record = { ...item };\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n return this.cache[collection] || [];\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n async create(collection, item) {\n if (!this.companyId) return item;\n\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = 'LD-' + Date.now().toString().slice(-5);\n }\n if (collection === 'notifications' && !item.number) {\n item.number = 'NT-' + Date.now().toString().slice(-5);\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n item.companyId = this.companyId;\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n item.email = email;\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item);\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase.from(table).insert([dbPayload]);\n if (error) console.error(`Error saving new record to ${table}:`, error);\n }\n\n return item;\n }\n\n async update(collection, id, updates) {\n if (!this.companyId) return null;\n\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to update user.');\n }\n } catch (err) {\n throw err;\n }\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated);\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id);\n if (error) console.error(`Error updating record in ${table}:`, error);\n }\n\n return updated;\n }\n\n async delete(collection, id) {\n if (!this.companyId) return;\n\n // 1. Update memory cache\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase\n .from(table)\n .delete()\n .eq('id', id);\n if (error) console.error(`Error deleting record from ${table}:`, error);\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: RELAY_LOGO_SVG,\n logoSmall: RELAY_LOGO_SMALL_SVG,\n markupPercent: 20,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default RELAY logos if user has not uploaded custom ones\n if (!merged.logo) merged.logo = RELAY_LOGO_SVG;\n if (!merged.logoSmall) merged.logoSmall = RELAY_LOGO_SMALL_SVG;\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n return merged;\n }\n return defaultSettings;\n }\n\n async saveSettings(settings) {\n if (!this.companyId) return;\n\n this.companySettings = settings;\n this.emit('settings', settings);\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n const { error } = await supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId);\n\n if (error) {\n console.error('Error saving company settings to Supabase:', error);\n throw error;\n }\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId) return true;\n return localStorage.getItem('simpro__seeded') === 'true';\n }\n\n save(collection, items) {\n this.cache[collection] = items;\n this.emit(collection, items);\n \n // If not running in cloud mode, fall back to localStorage to support the offline demo users list\n if (!this.companyId) {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n }\n\n markSeeded() {\n localStorage.setItem('simpro__seeded', 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n clearAll() {\n this.clearSync();\n if (!this.companyId) {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "0294d368bbea6f2edf50f8563679310122062d6a": {"t": 1785754206.7266524, "n": 38338, "h": "fc1d34e8d25b4d56", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents'\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g><text x=\"90\" y=\"35\" fill=\"#FF5C00\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"800\" letter-spacing=\"0.05em\">RELAY</text><text x=\"180\" y=\"35\" fill=\"#8A99AD\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"500\" letter-spacing=\"0.03em\">— Dispatch</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n\n // Pre-initialize empty arrays for collections to avoid undefined maps on startup\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n this.clearSync();\n }\n });\n\n // Auto-trigger sync if user already logged in at boot\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n if (currentUser && currentUser.companyId) {\n this.userId = currentUser.id;\n this.companyId = currentUser.companyId;\n this.initPromise = this.initializeCloudSync();\n }\n }\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id (except for profiles where we fetch tenant users)\n if (table !== 'profiles') {\n query = query.eq('company_id', this.companyId);\n }\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n const channel = supabase\n .channel('table-db-changes')\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item) {\n const record = { ...item };\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n return this.cache[collection] || [];\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n async create(collection, item) {\n if (!this.companyId) return item;\n\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = 'LD-' + Date.now().toString().slice(-5);\n }\n if (collection === 'notifications' && !item.number) {\n item.number = 'NT-' + Date.now().toString().slice(-5);\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n item.companyId = this.companyId;\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n item.email = email;\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item);\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase.from(table).insert([dbPayload]);\n if (error) console.error(`Error saving new record to ${table}:`, error);\n }\n\n return item;\n }\n\n async update(collection, id, updates) {\n if (!this.companyId) return null;\n\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to update user.');\n }\n } catch (err) {\n throw err;\n }\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated);\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id);\n if (error) console.error(`Error updating record in ${table}:`, error);\n }\n\n return updated;\n }\n\n async delete(collection, id) {\n if (!this.companyId) return;\n\n // 1. Update memory cache\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase\n .from(table)\n .delete()\n .eq('id', id);\n if (error) console.error(`Error deleting record from ${table}:`, error);\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: RELAY_LOGO_SVG,\n logoSmall: RELAY_LOGO_SMALL_SVG,\n markupPercent: 20,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n hideCompanyName: false,\n footerNote: 'Thank you for your business!'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default RELAY logos if user has not uploaded custom ones\n if (!merged.logo) merged.logo = RELAY_LOGO_SVG;\n if (!merged.logoSmall) merged.logoSmall = RELAY_LOGO_SMALL_SVG;\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n return merged;\n }\n return defaultSettings;\n }\n\n async saveSettings(settings) {\n if (!this.companyId) return;\n\n this.companySettings = settings;\n this.emit('settings', settings);\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n const { error } = await supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId);\n\n if (error) console.error('Error saving company settings to Supabase:', error);\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId) return true;\n return localStorage.getItem('simpro__seeded') === 'true';\n }\n\n save(collection, items) {\n this.cache[collection] = items;\n this.emit(collection, items);\n \n // If not running in cloud mode, fall back to localStorage to support the offline demo users list\n if (!this.companyId) {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n }\n\n markSeeded() {\n localStorage.setItem('simpro__seeded', 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n clearAll() {\n this.clearSync();\n if (!this.companyId) {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "70c10c98ffeb3b723c12476ff5377fa276d4b3fe": {"t": 1785754208.644382, "n": 38277, "h": "faa45d3590a795a4", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents'\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g><text x=\"90\" y=\"35\" fill=\"#FF5C00\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"800\" letter-spacing=\"0.05em\">RELAY</text><text x=\"180\" y=\"35\" fill=\"#8A99AD\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"500\" letter-spacing=\"0.03em\">— Dispatch</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"#FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 q 0.26728,-6.41461 3.52803,-20.04566 q 3.26076,-13.63104 7.80445,-26.94136 q 4.54368,-13.31031 8.87354,-23.94787 q 1.22947,-0.10691 2.45893,-0.10691 q 2.56585,0 5.39897,0.58801 q 2.83311,0.588 4.32986,4.16949 q 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 q 1.28292,0.64147 2.72621,0.64147 q 2.88657,0 7.3768,-3.1004 q 4.49023,-3.10039 8.49936,-9.62191 q 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 q -6.84225,1.81747 -16.94526,8.71318 q -10.04955,6.8957 -18.17473,16.3038 q -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 q 4.00913,-10.53065 18.01436,-22.07694 q 14.05869,-11.59976 34.05089,-20.36639 q 20.04565,-8.76663 42.06914,-10.90484 q 2.56584,-0.16036 4.97132,-0.16036 q 11.38593,0 19.77838,4.59714 q 8.4459,4.59713 12.6154,13.47068 q 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 q -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 q 8.87354,6.41461 19.5111,7.10953 q 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 q -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 q -5.50587,-4.7575 -10.47719,-11.65321 q -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n\n // Pre-initialize empty arrays for collections to avoid undefined maps on startup\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n this.clearSync();\n }\n });\n\n // Auto-trigger sync if user already logged in at boot\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n if (currentUser && currentUser.companyId) {\n this.userId = currentUser.id;\n this.companyId = currentUser.companyId;\n this.initPromise = this.initializeCloudSync();\n }\n }\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id (except for profiles where we fetch tenant users)\n if (table !== 'profiles') {\n query = query.eq('company_id', this.companyId);\n }\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n const channel = supabase\n .channel('table-db-changes')\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item) {\n const record = { ...item };\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n return this.cache[collection] || [];\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n async create(collection, item) {\n if (!this.companyId) return item;\n\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = 'LD-' + Date.now().toString().slice(-5);\n }\n if (collection === 'notifications' && !item.number) {\n item.number = 'NT-' + Date.now().toString().slice(-5);\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n item.companyId = this.companyId;\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n item.email = email;\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item);\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase.from(table).insert([dbPayload]);\n if (error) console.error(`Error saving new record to ${table}:`, error);\n }\n\n return item;\n }\n\n async update(collection, id, updates) {\n if (!this.companyId) return null;\n\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to update user.');\n }\n } catch (err) {\n throw err;\n }\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated);\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id);\n if (error) console.error(`Error updating record in ${table}:`, error);\n }\n\n return updated;\n }\n\n async delete(collection, id) {\n if (!this.companyId) return;\n\n // 1. Update memory cache\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase\n .from(table)\n .delete()\n .eq('id', id);\n if (error) console.error(`Error deleting record from ${table}:`, error);\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: RELAY_LOGO_SVG,\n logoSmall: RELAY_LOGO_SMALL_SVG,\n markupPercent: 20,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default RELAY logos if user has not uploaded custom ones\n if (!merged.logo) merged.logo = RELAY_LOGO_SVG;\n if (!merged.logoSmall) merged.logoSmall = RELAY_LOGO_SMALL_SVG;\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n return merged;\n }\n return defaultSettings;\n }\n\n async saveSettings(settings) {\n if (!this.companyId) return;\n\n this.companySettings = settings;\n this.emit('settings', settings);\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n const { error } = await supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId);\n\n if (error) console.error('Error saving company settings to Supabase:', error);\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId) return true;\n return localStorage.getItem('simpro__seeded') === 'true';\n }\n\n save(collection, items) {\n this.cache[collection] = items;\n this.emit(collection, items);\n \n // If not running in cloud mode, fall back to localStorage to support the offline demo users list\n if (!this.companyId) {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n }\n\n markSeeded() {\n localStorage.setItem('simpro__seeded', 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n clearAll() {\n this.clearSync();\n if (!this.companyId) {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "4fe91cba1b757f76dba24c08464afcb9582e1c5e": {"t": 1785754210.1308532, "n": 38213, "h": "e05efc0eff13ad14", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents'\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"%23FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g><text x=\"90\" y=\"35\" fill=\"%23FF5C00\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"800\" letter-spacing=\"0.05em\">RELAY</text><text x=\"180\" y=\"35\" fill=\"%238A99AD\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"500\" letter-spacing=\"0.03em\">— Dispatch</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"%23FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n\n // Pre-initialize empty arrays for collections to avoid undefined maps on startup\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n this.clearSync();\n }\n });\n\n // Auto-trigger sync if user already logged in at boot\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n if (currentUser && currentUser.companyId) {\n this.userId = currentUser.id;\n this.companyId = currentUser.companyId;\n this.initPromise = this.initializeCloudSync();\n }\n }\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = {\n name: comp.name,\n ...(comp.settings || {})\n };\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id (except for profiles where we fetch tenant users)\n if (table !== 'profiles') {\n query = query.eq('company_id', this.companyId);\n }\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n const channel = supabase\n .channel('table-db-changes')\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item) {\n const record = { ...item };\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n return this.cache[collection] || [];\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n async create(collection, item) {\n if (!this.companyId) return item;\n\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = 'LD-' + Date.now().toString().slice(-5);\n }\n if (collection === 'notifications' && !item.number) {\n item.number = 'NT-' + Date.now().toString().slice(-5);\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n item.companyId = this.companyId;\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n item.email = email;\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item);\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase.from(table).insert([dbPayload]);\n if (error) console.error(`Error saving new record to ${table}:`, error);\n }\n\n return item;\n }\n\n async update(collection, id, updates) {\n if (!this.companyId) return null;\n\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to update user.');\n }\n } catch (err) {\n throw err;\n }\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated);\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id);\n if (error) console.error(`Error updating record in ${table}:`, error);\n }\n\n return updated;\n }\n\n async delete(collection, id) {\n if (!this.companyId) return;\n\n // 1. Update memory cache\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase\n .from(table)\n .delete()\n .eq('id', id);\n if (error) console.error(`Error deleting record from ${table}:`, error);\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: RELAY_LOGO_SVG,\n logoSmall: RELAY_LOGO_SMALL_SVG,\n markupPercent: 20,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default RELAY logos if user has not uploaded custom ones\n if (!merged.logo) merged.logo = RELAY_LOGO_SVG;\n if (!merged.logoSmall) merged.logoSmall = RELAY_LOGO_SMALL_SVG;\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n return merged;\n }\n return defaultSettings;\n }\n\n async saveSettings(settings) {\n if (!this.companyId) return;\n\n this.companySettings = settings;\n this.emit('settings', settings);\n\n // Save settings directly into the company JSONB column and keep top-level name in sync\n const { error } = await supabase\n .from('companies')\n .update({ \n settings,\n name: settings.name\n })\n .eq('id', this.companyId);\n\n if (error) console.error('Error saving company settings to Supabase:', error);\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId) return true;\n return localStorage.getItem('simpro__seeded') === 'true';\n }\n\n save(collection, items) {\n this.cache[collection] = items;\n this.emit(collection, items);\n \n // If not running in cloud mode, fall back to localStorage to support the offline demo users list\n if (!this.companyId) {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n }\n\n markSeeded() {\n localStorage.setItem('simpro__seeded', 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n clearAll() {\n this.clearSync();\n if (!this.companyId) {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "d5259524a5f622ae9c3af1ea492b5d23aa3b880b": {"t": 1785754211.6351378, "n": 38083, "h": "1505b16dbacb46f2", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n technicians: 'profiles',\n userTypes: 'user_types',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents'\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"%23FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g><text x=\"90\" y=\"35\" fill=\"%23FF5C00\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"800\" letter-spacing=\"0.05em\">RELAY</text><text x=\"180\" y=\"35\" fill=\"%238A99AD\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"500\" letter-spacing=\"0.03em\">— Dispatch</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"%23FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n\n // Pre-initialize empty arrays for collections to avoid undefined maps on startup\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n this.clearSync();\n }\n });\n\n // Auto-trigger sync if user already logged in at boot\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n if (currentUser && currentUser.companyId) {\n this.userId = currentUser.id;\n this.companyId = currentUser.companyId;\n this.initPromise = this.initializeCloudSync();\n }\n }\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = comp.settings || {};\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id (except for profiles where we fetch tenant users)\n if (table !== 'profiles') {\n query = query.eq('company_id', this.companyId);\n }\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n const channel = supabase\n .channel('table-db-changes')\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...(this.cache[col] || [])];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item) {\n const record = { ...item };\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n return this.cache[collection] || [];\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n async create(collection, item) {\n if (!this.companyId) return item;\n\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = 'LD-' + Date.now().toString().slice(-5);\n }\n if (collection === 'notifications' && !item.number) {\n item.number = 'NT-' + Date.now().toString().slice(-5);\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n item.companyId = this.companyId;\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...(this.cache[collection] || [])];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n item.email = email;\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item);\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase.from(table).insert([dbPayload]);\n if (error) console.error(`Error saving new record to ${table}:`, error);\n }\n\n return item;\n }\n\n async update(collection, id, updates) {\n if (!this.companyId) return null;\n\n const items = [...(this.cache[collection] || [])];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to update user.');\n }\n } catch (err) {\n throw err;\n }\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated);\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id);\n if (error) console.error(`Error updating record in ${table}:`, error);\n }\n\n return updated;\n }\n\n async delete(collection, id) {\n if (!this.companyId) return;\n\n // 1. Update memory cache\n const items = (this.cache[collection] || []).filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase\n .from(table)\n .delete()\n .eq('id', id);\n if (error) console.error(`Error deleting record from ${table}:`, error);\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: RELAY_LOGO_SVG,\n logoSmall: RELAY_LOGO_SMALL_SVG,\n markupPercent: 20,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default RELAY logos if user has not uploaded custom ones\n if (!merged.logo) merged.logo = RELAY_LOGO_SVG;\n if (!merged.logoSmall) merged.logoSmall = RELAY_LOGO_SMALL_SVG;\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n return merged;\n }\n return defaultSettings;\n }\n\n async saveSettings(settings) {\n if (!this.companyId) return;\n\n this.companySettings = settings;\n this.emit('settings', settings);\n\n // Save settings directly into the company JSONB column\n const { error } = await supabase\n .from('companies')\n .update({ settings })\n .eq('id', this.companyId);\n\n if (error) console.error('Error saving company settings to Supabase:', error);\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId) return true;\n return localStorage.getItem('simpro__seeded') === 'true';\n }\n\n save(collection, items) {\n this.cache[collection] = items;\n this.emit(collection, items);\n \n // If not running in cloud mode, fall back to localStorage to support the offline demo users list\n if (!this.companyId) {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n }\n\n markSeeded() {\n localStorage.setItem('simpro__seeded', 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n clearAll() {\n this.clearSync();\n if (!this.companyId) {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "d4be27b025f8f1f01aeea09106b5c48bd1c08ecb": {"t": 1785754213.047754, "n": 38048, "h": "87832436ae9422e2", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n profiles: 'profiles',\n userTypes: 'user_types',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents'\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"%23FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g><text x=\"90\" y=\"35\" fill=\"%23FF5C00\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"800\" letter-spacing=\"0.05em\">RELAY</text><text x=\"180\" y=\"35\" fill=\"%238A99AD\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"500\" letter-spacing=\"0.03em\">— Dispatch</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"%23FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n\n // Pre-initialize empty arrays for collections to avoid undefined maps on startup\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n this.clearSync();\n }\n });\n\n // Auto-trigger sync if user already logged in at boot\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n if (currentUser && currentUser.companyId) {\n this.userId = currentUser.id;\n this.companyId = currentUser.companyId;\n this.initPromise = this.initializeCloudSync();\n }\n }\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = comp.settings || {};\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id (except for profiles where we fetch tenant users)\n if (table !== 'profiles') {\n query = query.eq('company_id', this.companyId);\n }\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n const channel = supabase\n .channel('table-db-changes')\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...this.cache[col]];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.force_password_change !== undefined) {\n record.forcePasswordChange = record.force_password_change;\n delete record.force_password_change;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item) {\n const record = { ...item };\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.forcePasswordChange !== undefined) {\n record.force_password_change = record.forcePasswordChange;\n delete record.forcePasswordChange;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n return this.cache[collection] || [];\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n async create(collection, item) {\n if (!this.companyId) return item;\n\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = 'LD-' + Date.now().toString().slice(-5);\n }\n if (collection === 'notifications' && !item.number) {\n item.number = 'NT-' + Date.now().toString().slice(-5);\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n item.companyId = this.companyId;\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...this.cache[collection]];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const email = `${item.username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n item.email = email;\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'create',\n email: item.email,\n username: item.username,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to create user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.username === item.username);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.username !== item.username);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item);\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase.from(table).insert([dbPayload]);\n if (error) console.error(`Error saving new record to ${table}:`, error);\n }\n\n return item;\n }\n\n async update(collection, id, updates) {\n if (!this.companyId) return null;\n\n const items = [...this.cache[collection]];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n\n // Special Case: Updating a technician profile/password via UI\n if (collection === 'technicians') {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const companySlug = this.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n const username = updates.username || previous.username;\n const email = `${username.toLowerCase().trim()}@${companySlug}.fieldforge.internal`;\n updates.email = email;\n\n if (updates.password) {\n updates.forcePasswordChange = true;\n }\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n action: 'update',\n userId: id,\n email: email,\n username: username,\n name: updates.name || previous.name,\n role: updates.role || previous.role,\n userTypeId: updates.userTypeId || previous.userTypeId,\n color: updates.color || previous.color,\n payRate: updates.payRate !== undefined ? updates.payRate : previous.payRate,\n password: updates.password\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to update user.');\n }\n } catch (err) {\n throw err;\n }\n }\n\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated);\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id);\n if (error) console.error(`Error updating record in ${table}:`, error);\n }\n\n return updated;\n }\n\n async delete(collection, id) {\n if (!this.companyId) return;\n\n // 1. Update memory cache\n const items = this.cache[collection].filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase\n .from(table)\n .delete()\n .eq('id', id);\n if (error) console.error(`Error deleting record from ${table}:`, error);\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: RELAY_LOGO_SVG,\n logoSmall: RELAY_LOGO_SMALL_SVG,\n markupPercent: 20,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default RELAY logos if user has not uploaded custom ones\n if (!merged.logo) merged.logo = RELAY_LOGO_SVG;\n if (!merged.logoSmall) merged.logoSmall = RELAY_LOGO_SMALL_SVG;\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n return merged;\n }\n return defaultSettings;\n }\n\n async saveSettings(settings) {\n if (!this.companyId) return;\n\n this.companySettings = settings;\n this.emit('settings', settings);\n\n // Save settings directly into the company JSONB column\n const { error } = await supabase\n .from('companies')\n .update({ settings })\n .eq('id', this.companyId);\n\n if (error) console.error('Error saving company settings to Supabase:', error);\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId) return true;\n return localStorage.getItem('simpro__seeded') === 'true';\n }\n\n save(collection, items) {\n this.cache[collection] = items;\n this.emit(collection, items);\n \n // If not running in cloud mode, fall back to localStorage to support the offline demo users list\n if (!this.companyId) {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n }\n\n markSeeded() {\n localStorage.setItem('simpro__seeded', 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n clearAll() {\n this.clearSync();\n if (!this.companyId) {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "4b1cef56e0e36625f3002d0b0fae8e1ae5d74695": {"t": 1785754214.5610344, "n": 35791, "h": "221cde9fd09dc661", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n profiles: 'profiles',\n userTypes: 'user_types',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents'\n};\n\nexport const RELAY_LOGO_SVG = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg viewBox=\"0 0 320 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"%23FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g><text x=\"90\" y=\"35\" fill=\"%23FF5C00\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"800\" letter-spacing=\"0.05em\">RELAY</text><text x=\"180\" y=\"35\" fill=\"%238A99AD\" font-family=\"'Inter', -apple-system, sans-serif\" font-size=\"24\" font-weight=\"500\" letter-spacing=\"0.03em\">— Dispatch</text></svg>`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg viewBox=\"0 0 75.592812 53.598302\" xmlns=\"http://www.w3.org/2000/svg\"><g fill=\"%23FF5C00\"><g transform=\"translate(-19.023693,-210.20382)\"><g transform=\"matrix(0.3804654,0,0,0.3804654,-83.598864,122.48096)\"><g transform=\"translate(107.79013)\"><g transform=\"translate(-22.948867,-9.0404629)\"><path d=\"m 267.58275,347.28778 q 0.0535,9.78228 1.22947,17.15908 1.22947,7.3768 3.74185,11.27903 -2.40547,2.29856 -7.16298,3.42112 -4.7575,1.17601 -7.69753,1.17601 -9.515,-0.10691 -13.31031,-7.10952 -3.31422,-6.20079 -3.31422,-15.92962 0,-1.28292 0.26728,-7.64407 0.26728,-6.41461 3.52803,-20.04566 3.26076,-13.63104 7.80445,-26.94136 4.54368,-13.31031 8.87354,-23.94787 1.22947,-0.10691 2.45893,-0.10691 2.56585,0 5.39897,0.58801 2.83311,0.588 4.32986,4.16949 0.69491,1.65711 0.69491,4.38332 0,3.15385 -0.90873,7.64407 -2.19166,10.26338 -3.63495,20.63366 1.28292,0.64147 2.72621,0.64147 2.88657,0 7.3768,-3.1004 4.49023,-3.10039 8.49936,-9.62191 3.79531,-6.09388 3.79531,-12.34813 v -0.80182 q -1.12256,-11.33248 -7.69753,-14.80706 -3.74186,-1.97784 -8.44591,-1.97784 -3.58149,0 -10.47719,1.87093 -6.84225,1.81747 -16.94526,8.71318 -10.04955,6.8957 -18.17473,16.3038 -8.07171,9.35464 -11.6532,19.5111 -1.92439,5.29205 -1.92439,10.31683 0,4.54368 1.60366,8.87354 -5.66624,0.96219 -10.37029,0.96219 -6.41461,0 -12.82922,-2.40547 -6.36115,-2.45894 -9.14081,-9.03391 -1.33638,-3.26076 -1.33638,-6.89571 0,-3.6884 1.38983,-7.80444 4.00913,-10.53065 18.01436,-22.07694 14.05869,-11.59976 34.05089,-20.36639 20.04565,-8.76663 42.06914,-10.90484 2.56584,-0.16036 4.97132,-0.16036 11.38593,0 19.77838,4.59714 8.4459,4.59713 12.6154,13.47068 3.84877,8.07171 3.84877,17.42635 0,0.80183 -0.21382,6.36116 -0.16037,5.55932 -4.49023,14.91396 -4.32986,9.30119 -12.1343,15.34161 -7.80444,6.04042 -18.49546,6.46806 8.87354,6.41461 19.5111,7.10953 1.17601,0.0534 2.35203,0.0534 9.67537,0 20.90093,-5.07823 0.26728,3.79531 0.26728,7.10953 0,9.0339 -2.35203,16.14343 -2.35202,7.10952 -7.32334,10.90484 -4.97132,3.79531 -10.63756,4.49022 -2.13821,0.26728 -4.11604,0.26728 -3.42113,0 -6.46807,-0.74837 -5.8266,-1.38984 -11.38593,-6.20079 -5.50587,-4.7575 -10.47719,-11.65321 -4.91787,-6.8957 -8.98046,-14.59324 z\" /><path d=\"m 370.43971,354.90523 -15.22212,-9.62244 -16.8901,6.24731 4.44762,-17.45058 -11.16089,-14.13291 17.97088,-1.16261 9.9923,-14.98194 6.659,16.73206 17.33647,4.87357 -13.85539,11.50358 z\" /><path d=\"m 348.39147,304.71497 -3.17554,-13.39823 -12.4452,-5.89179 11.76117,-7.16041 1.75766,-13.65677 10.44434,8.97286 13.53148,-2.54853 -5.30619,12.70593 6.60527,12.08167 -13.72376,-1.12015 z\" /><path d=\"m 334.8158,263.90614 -5.7247,-6.06772 -8.31437,0.67863 4.00173,-7.31954 -3.21471,-7.6977 8.19789,1.544 6.32758,-5.43609 1.06484,8.27379 7.12538,4.33803 -7.53978,3.56946 z\" /></g></g></g></svg>`)}`;\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n\n // Pre-initialize empty arrays for collections to avoid undefined maps on startup\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n this.clearSync();\n }\n });\n\n // Auto-trigger sync if user already logged in at boot\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n if (currentUser && currentUser.companyId) {\n this.userId = currentUser.id;\n this.companyId = currentUser.companyId;\n this.initPromise = this.initializeCloudSync();\n }\n }\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = comp.settings || {};\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id (except for profiles where we fetch tenant users)\n if (table !== 'profiles') {\n query = query.eq('company_id', this.companyId);\n }\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n const channel = supabase\n .channel('table-db-changes')\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...this.cache[col]];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item) {\n const record = { ...item };\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n return this.cache[collection] || [];\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n async create(collection, item) {\n if (!this.companyId) return item;\n\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = 'LD-' + Date.now().toString().slice(-5);\n }\n if (collection === 'notifications' && !item.number) {\n item.number = 'NT-' + Date.now().toString().slice(-5);\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n item.companyId = this.companyId;\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...this.cache[collection]];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n email: item.email,\n password: item.password,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to invite user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.email === item.email);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.email !== item.email);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item);\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase.from(table).insert([dbPayload]);\n if (error) console.error(`Error saving new record to ${table}:`, error);\n }\n\n return item;\n }\n\n async update(collection, id, updates) {\n if (!this.companyId) return null;\n\n const items = [...this.cache[collection]];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated);\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id);\n if (error) console.error(`Error updating record in ${table}:`, error);\n }\n\n return updated;\n }\n\n async delete(collection, id) {\n if (!this.companyId) return;\n\n // 1. Update memory cache\n const items = this.cache[collection].filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase\n .from(table)\n .delete()\n .eq('id', id);\n if (error) console.error(`Error deleting record from ${table}:`, error);\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Company Name',\n abn: '',\n phone: '',\n domain: '',\n email: '',\n address: '',\n website: '',\n logo: RELAY_LOGO_SVG,\n logoSmall: RELAY_LOGO_SMALL_SVG,\n markupPercent: 20,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Fallback to default RELAY logos if user has not uploaded custom ones\n if (!merged.logo) merged.logo = RELAY_LOGO_SVG;\n if (!merged.logoSmall) merged.logoSmall = RELAY_LOGO_SMALL_SVG;\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n return merged;\n }\n return defaultSettings;\n }\n\n async saveSettings(settings) {\n if (!this.companyId) return;\n\n this.companySettings = settings;\n this.emit('settings', settings);\n\n // Save settings directly into the company JSONB column\n const { error } = await supabase\n .from('companies')\n .update({ settings })\n .eq('id', this.companyId);\n\n if (error) console.error('Error saving company settings to Supabase:', error);\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId) return true;\n return localStorage.getItem('simpro__seeded') === 'true';\n }\n\n save(collection, items) {\n this.cache[collection] = items;\n this.emit(collection, items);\n \n // If not running in cloud mode, fall back to localStorage to support the offline demo users list\n if (!this.companyId) {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n }\n\n markSeeded() {\n localStorage.setItem('simpro__seeded', 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n clearAll() {\n this.clearSync();\n if (!this.companyId) {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "4bdc1be164b606b58a7504b61ec71351639b9eee": {"t": 1785754216.0052826, "n": 29545, "h": "f8daa477bed2193f", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n profiles: 'profiles',\n userTypes: 'user_types',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents'\n};\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n\n // Pre-initialize empty arrays for collections to avoid undefined maps on startup\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n this.clearSync();\n }\n });\n\n // Auto-trigger sync if user already logged in at boot\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n if (currentUser && currentUser.companyId) {\n this.userId = currentUser.id;\n this.companyId = currentUser.companyId;\n this.initPromise = this.initializeCloudSync();\n }\n }\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = comp.settings || {};\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id (except for profiles where we fetch tenant users)\n if (table !== 'profiles') {\n query = query.eq('company_id', this.companyId);\n }\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n const channel = supabase\n .channel('table-db-changes')\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...this.cache[col]];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item) {\n const record = { ...item };\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n return this.cache[collection] || [];\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n async create(collection, item) {\n if (!this.companyId) return item;\n\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = 'LD-' + Date.now().toString().slice(-5);\n }\n if (collection === 'notifications' && !item.number) {\n item.number = 'NT-' + Date.now().toString().slice(-5);\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n item.companyId = this.companyId;\n\n // 1. Update memory cache immediately for responsiveness (Optimistic UI)\n const items = [...this.cache[collection]];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // Special Case: Creating a user (technician) via UI requires a secure invitation Netlify function\n if (collection === 'technicians') {\n try {\n const { data: { session } } = await supabase.auth.getSession();\n if (!session) throw new Error('Authentication session expired. Please log in again.');\n\n const res = await fetch('/.netlify/functions/invite-user', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${session.access_token}`\n },\n body: JSON.stringify({\n email: item.email,\n name: item.name,\n role: item.role,\n userTypeId: item.userTypeId,\n color: item.color,\n payRate: item.payRate\n })\n });\n\n const data = await res.json();\n if (!res.ok || !data.success) {\n throw new Error(data.error || 'Failed to invite user.');\n }\n\n // Update local item ID with the real auth user UUID returned from database\n item.id = data.user.id;\n \n const cachedItems = [...this.cache.technicians];\n const idx = cachedItems.findIndex(x => x.email === item.email);\n if (idx !== -1) {\n cachedItems[idx] = item;\n this.cache.technicians = cachedItems;\n this.emit('technicians', cachedItems);\n }\n\n return item;\n } catch (err) {\n // Rollback cache update on failure\n this.cache.technicians = this.cache.technicians.filter(x => x.email !== item.email);\n this.emit('technicians', this.cache.technicians);\n throw err;\n }\n }\n\n // 2. Perform DB write in background for other collections\n const dbPayload = this.denormalizeRecord(item);\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase.from(table).insert([dbPayload]);\n if (error) console.error(`Error saving new record to ${table}:`, error);\n }\n\n return item;\n }\n\n async update(collection, id, updates) {\n if (!this.companyId) return null;\n\n const items = [...this.cache[collection]];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated);\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id);\n if (error) console.error(`Error updating record in ${table}:`, error);\n }\n\n return updated;\n }\n\n async delete(collection, id) {\n if (!this.companyId) return;\n\n // 1. Update memory cache\n const items = this.cache[collection].filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase\n .from(table)\n .delete()\n .eq('id', id);\n if (error) console.error(`Error deleting record from ${table}:`, error);\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Apex Power Services',\n abn: '51 234 567 890',\n phone: '(02) 6882 4400',\n domain: 'apexpowerservices.com.au',\n email: 'admin@apexpowerservices.com.au',\n address: '14 Yarrandale Rd, Dubbo NSW 2830',\n markupPercent: 20,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n return merged;\n }\n return defaultSettings;\n }\n\n async saveSettings(settings) {\n if (!this.companyId) return;\n\n this.companySettings = settings;\n this.emit('settings', settings);\n\n // Save settings directly into the company JSONB column\n const { error } = await supabase\n .from('companies')\n .update({ settings })\n .eq('id', this.companyId);\n\n if (error) console.error('Error saving company settings to Supabase:', error);\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId) return true;\n return localStorage.getItem('simpro__seeded') === 'true';\n }\n\n save(collection, items) {\n this.cache[collection] = items;\n this.emit(collection, items);\n \n // If not running in cloud mode, fall back to localStorage to support the offline demo users list\n if (!this.companyId) {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n }\n\n markSeeded() {\n localStorage.setItem('simpro__seeded', 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n clearAll() {\n this.clearSync();\n if (!this.companyId) {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n }\n }\n}\n\nexport const store = new DataStore();\n"}, "dd1139abe049f599a4959402b0228983013b1dcb": {"t": 1785754217.7085261, "n": 27918, "h": "6a1c49f7deb7cb67", "c": "// ============================================\n// FIELDFORGE — CLOUD DATA STORE (Supabase Sync)\n// ============================================\nimport { supabase } from '../utils/supabase.js';\nimport { prebuiltForms } from './prebuiltForms.js';\n\n// Table name mappings to match local collection keys with PostgreSQL tables\nconst TABLE_MAP = {\n companies: 'companies',\n profiles: 'profiles',\n userTypes: 'user_types',\n customers: 'customers',\n assets: 'assets',\n maintenancePlans: 'maintenance_plans',\n taskTemplates: 'task_templates',\n quotes: 'quotes',\n jobs: 'jobs',\n invoices: 'invoices',\n stock: 'stock',\n timesheets: 'timesheets',\n contractors: 'contractors',\n suppliers: 'suppliers',\n purchaseOrders: 'purchase_orders',\n notifications: 'notifications',\n formTemplates: 'form_templates',\n formInstances: 'form_instances',\n kits: 'kits',\n documents: 'documents'\n};\n\nclass DataStore {\n constructor() {\n this.listeners = {};\n this.cache = {};\n this.companySettings = null;\n this.companyId = null;\n this.userId = null;\n this.subscriptions = [];\n this.initPromise = null;\n\n // Pre-initialize empty arrays for collections to avoid undefined maps on startup\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n\n // Handle authentication state changes\n supabase.auth.onAuthStateChange((event, session) => {\n if (session) {\n this.userId = session.user.id;\n const userMeta = JSON.parse(localStorage.getItem('currentUser') || '{}');\n this.companyId = userMeta.companyId || null;\n if (this.companyId) {\n this.initPromise = this.initializeCloudSync();\n }\n } else {\n this.clearSync();\n }\n });\n\n // Auto-trigger sync if user already logged in at boot\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n if (currentUser && currentUser.companyId) {\n this.userId = currentUser.id;\n this.companyId = currentUser.companyId;\n this.initPromise = this.initializeCloudSync();\n }\n }\n\n // Fetch all tenant data into memory cache and subscribe to real-time updates\n async initializeCloudSync() {\n if (!this.companyId) return;\n\n try {\n // 1. Fetch Company Settings\n const { data: comp, error: compErr } = await supabase\n .from('companies')\n .select('*')\n .eq('id', this.companyId)\n .single();\n if (!compErr && comp) {\n this.companySettings = comp.settings || {};\n }\n\n // 2. Fetch all collections in parallel\n const collections = Object.keys(TABLE_MAP);\n const promises = collections.map(async (col) => {\n const table = TABLE_MAP[col];\n if (table === 'companies') return;\n\n let query = supabase.from(table).select('*');\n \n // Scope queries by company_id (except for profiles where we fetch tenant users)\n if (table !== 'profiles') {\n query = query.eq('company_id', this.companyId);\n }\n\n const { data, error } = await query;\n if (!error && data) {\n // Normalize snake_case column names back to camelCase for the frontend\n this.cache[col] = this.normalizeData(data, col);\n }\n });\n\n await Promise.all(promises);\n\n // Seed form templates if database is empty\n if (this.cache.formTemplates.length === 0) {\n await this.seedFormTemplates();\n }\n\n // 3. Set up Realtime listener subscriptions\n this.subscribeRealtime();\n\n // Emit loaded event for all collections\n collections.forEach(col => {\n this.emit(col, this.cache[col]);\n });\n this.emit('settings', this.getSettings());\n\n } catch (e) {\n console.error('Error initializing Supabase sync:', e);\n }\n }\n\n subscribeRealtime() {\n this.clearSubscriptions();\n\n const channel = supabase\n .channel('table-db-changes')\n .on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {\n const table = payload.table;\n const col = Object.keys(TABLE_MAP).find(key => TABLE_MAP[key] === table);\n if (!col) return;\n\n // Skip records from other companies\n if (payload.new && payload.new.company_id && payload.new.company_id !== this.companyId) return;\n if (payload.old && payload.old.company_id && payload.old.company_id !== this.companyId) return;\n\n const items = [...this.cache[col]];\n \n if (payload.eventType === 'INSERT') {\n const newItem = this.normalizeRecord(payload.new, col);\n if (!items.some(x => x.id === newItem.id)) {\n items.push(newItem);\n }\n } else if (payload.eventType === 'UPDATE') {\n const updatedItem = this.normalizeRecord(payload.new, col);\n const idx = items.findIndex(x => x.id === updatedItem.id);\n if (idx !== -1) {\n items[idx] = updatedItem;\n }\n } else if (payload.eventType === 'DELETE') {\n const idx = items.findIndex(x => x.id === payload.old.id);\n if (idx !== -1) {\n items.splice(idx, 1);\n }\n }\n\n this.cache[col] = items;\n this.emit(col, items);\n })\n .subscribe();\n\n this.subscriptions.push(channel);\n }\n\n clearSubscriptions() {\n this.subscriptions.forEach(sub => supabase.removeChannel(sub));\n this.subscriptions = [];\n }\n\n clearSync() {\n this.clearSubscriptions();\n this.companyId = null;\n this.userId = null;\n this.companySettings = null;\n Object.keys(TABLE_MAP).forEach(col => {\n this.cache[col] = [];\n });\n }\n\n // Normalization Helpers (translates snake_case keys from Postgres -> camelCase for frontend)\n normalizeData(data, collection) {\n return data.map(item => this.normalizeRecord(item, collection));\n }\n\n normalizeRecord(item, collection) {\n if (!item) return null;\n const record = { ...item };\n\n // Standard snake_case mappings used in SQL schema\n if (record.company_id !== undefined) {\n record.companyId = record.company_id;\n delete record.company_id;\n }\n if (record.user_type_id !== undefined) {\n record.userTypeId = record.user_type_id;\n delete record.user_type_id;\n }\n if (record.pay_rate !== undefined) {\n record.payRate = parseFloat(record.pay_rate);\n delete record.pay_rate;\n }\n if (record.first_name !== undefined) {\n record.firstName = record.first_name;\n delete record.first_name;\n }\n if (record.last_name !== undefined) {\n record.lastName = record.last_name;\n delete record.last_name;\n }\n if (record.portal_token !== undefined) {\n record.portalToken = record.portal_token;\n delete record.portal_token;\n }\n if (record.owner_type !== undefined) {\n record.ownerType = record.owner_type;\n delete record.owner_type;\n }\n if (record.customer_id !== undefined) {\n record.customerId = record.customer_id;\n delete record.customer_id;\n }\n if (record.customer_name !== undefined) {\n record.customerName = record.customer_name;\n delete record.customer_name;\n }\n if (record.current_meter !== undefined) {\n record.currentMeter = parseFloat(record.current_meter);\n delete record.current_meter;\n }\n if (record.recovery_rate !== undefined) {\n record.recoveryRate = parseFloat(record.recovery_rate);\n delete record.recovery_rate;\n }\n if (record.meter_interval !== undefined) {\n record.meterInterval = parseFloat(record.meter_interval);\n delete record.meter_interval;\n }\n if (record.last_triggered_meter !== undefined) {\n record.lastTriggeredMeter = parseFloat(record.last_triggered_meter);\n delete record.last_triggered_meter;\n }\n if (record.next_service_date !== undefined) {\n record.nextServiceDate = record.next_service_date;\n delete record.next_service_date;\n }\n if (record.collision_merging !== undefined) {\n record.collisionMerging = record.collision_merging;\n delete record.collision_merging;\n }\n if (record.line_items !== undefined) {\n record.lineItems = record.line_items;\n delete record.line_items;\n }\n if (record.valid_until !== undefined) {\n record.validUntil = record.valid_until;\n delete record.valid_until;\n }\n if (record.contact_name !== undefined) {\n record.contactName = record.contact_name;\n delete record.contact_name;\n }\n if (record.site_address !== undefined) {\n record.siteAddress = record.site_address;\n delete record.site_address;\n }\n if (record.technician_id !== undefined) {\n record.technicianId = record.technician_id;\n delete record.technician_id;\n }\n if (record.technician_name !== undefined) {\n record.technicianName = record.technician_name;\n delete record.technician_name;\n }\n if (record.quote_id !== undefined) {\n record.quoteId = record.quote_id;\n delete record.quote_id;\n }\n if (record.asset_id !== undefined) {\n record.assetId = record.asset_id;\n delete record.asset_id;\n }\n if (record.scheduled_date !== undefined) {\n record.scheduledDate = record.scheduled_date;\n delete record.scheduled_date;\n }\n if (record.estimated_hours !== undefined) {\n record.estimatedHours = parseFloat(record.estimated_hours);\n delete record.estimated_hours;\n }\n if (record.labor_cost !== undefined) {\n record.laborCost = parseFloat(record.labor_cost);\n delete record.labor_cost;\n }\n if (record.material_cost !== undefined) {\n record.materialCost = parseFloat(record.material_cost);\n delete record.material_cost;\n }\n if (record.job_id !== undefined) {\n record.jobId = record.job_id;\n delete record.job_id;\n }\n if (record.due_date !== undefined) {\n record.dueDate = record.due_date;\n delete record.due_date;\n }\n if (record.cost_price !== undefined) {\n record.costPrice = parseFloat(record.cost_price);\n delete record.cost_price;\n }\n if (record.unit_price !== undefined) {\n record.unitPrice = parseFloat(record.unit_price);\n delete record.unit_price;\n }\n if (record.reorder_level !== undefined) {\n record.reorderLevel = parseFloat(record.reorder_level);\n delete record.reorder_level;\n }\n if (record.duration_hours !== undefined) {\n record.durationHours = parseFloat(record.duration_hours);\n delete record.duration_hours;\n }\n if (record.approved_by !== undefined) {\n record.approvedBy = record.approved_by;\n delete record.approved_by;\n }\n if (record.supplier_id !== undefined) {\n record.supplierId = record.supplier_id;\n delete record.supplier_id;\n }\n if (record.supplier_name !== undefined) {\n record.supplierName = record.supplier_name;\n delete record.supplier_name;\n }\n if (record.template_id !== undefined) {\n record.templateId = record.template_id;\n delete record.template_id;\n }\n if (record.file_path !== undefined) {\n record.filePath = record.file_path;\n delete record.file_path;\n }\n if (record.created_at !== undefined) {\n record.createdAt = record.created_at;\n delete record.created_at;\n }\n if (record.updated_at !== undefined) {\n record.updatedAt = record.updated_at;\n delete record.updated_at;\n }\n\n return record;\n }\n\n // De-normalize camelCase fields -> snake_case schema columns for database updates\n denormalizeRecord(item) {\n const record = { ...item };\n\n if (record.companyId !== undefined) {\n record.company_id = record.companyId;\n delete record.companyId;\n }\n if (record.userTypeId !== undefined) {\n record.user_type_id = record.userTypeId;\n delete record.userTypeId;\n }\n if (record.payRate !== undefined) {\n record.pay_rate = record.payRate;\n delete record.payRate;\n }\n if (record.firstName !== undefined) {\n record.first_name = record.firstName;\n delete record.firstName;\n }\n if (record.lastName !== undefined) {\n record.last_name = record.lastName;\n delete record.lastName;\n }\n if (record.portalToken !== undefined) {\n record.portal_token = record.portalToken;\n delete record.portalToken;\n }\n if (record.ownerType !== undefined) {\n record.owner_type = record.ownerType;\n delete record.ownerType;\n }\n if (record.customerId !== undefined) {\n record.customer_id = record.customerId;\n delete record.customerId;\n }\n if (record.customerName !== undefined) {\n record.customer_name = record.customerName;\n delete record.customerName;\n }\n if (record.currentMeter !== undefined) {\n record.current_meter = record.currentMeter;\n delete record.currentMeter;\n }\n if (record.recoveryRate !== undefined) {\n record.recovery_rate = record.recoveryRate;\n delete record.recoveryRate;\n }\n if (record.meterInterval !== undefined) {\n record.meter_interval = record.meterInterval;\n delete record.meterInterval;\n }\n if (record.lastTriggeredMeter !== undefined) {\n record.last_triggered_meter = record.lastTriggeredMeter;\n delete record.lastTriggeredMeter;\n }\n if (record.nextServiceDate !== undefined) {\n record.next_service_date = record.nextServiceDate;\n delete record.nextServiceDate;\n }\n if (record.collisionMerging !== undefined) {\n record.collision_merging = record.collisionMerging;\n delete record.collisionMerging;\n }\n if (record.lineItems !== undefined) {\n record.line_items = record.lineItems;\n delete record.lineItems;\n }\n if (record.validUntil !== undefined) {\n record.valid_until = record.validUntil;\n delete record.validUntil;\n }\n if (record.contactName !== undefined) {\n record.contact_name = record.contactName;\n delete record.contactName;\n }\n if (record.siteAddress !== undefined) {\n record.site_address = record.siteAddress;\n delete record.siteAddress;\n }\n if (record.technicianId !== undefined) {\n record.technician_id = record.technicianId;\n delete record.technicianId;\n }\n if (record.technicianName !== undefined) {\n record.technician_name = record.technicianName;\n delete record.technicianName;\n }\n if (record.quoteId !== undefined) {\n record.quote_id = record.quoteId;\n delete record.quoteId;\n }\n if (record.assetId !== undefined) {\n record.asset_id = record.assetId;\n delete record.assetId;\n }\n if (record.scheduledDate !== undefined) {\n record.scheduled_date = record.scheduledDate;\n delete record.scheduledDate;\n }\n if (record.estimatedHours !== undefined) {\n record.estimated_hours = record.estimatedHours;\n delete record.estimatedHours;\n }\n if (record.laborCost !== undefined) {\n record.labor_cost = record.laborCost;\n delete record.laborCost;\n }\n if (record.materialCost !== undefined) {\n record.material_cost = record.materialCost;\n delete record.materialCost;\n }\n if (record.jobId !== undefined) {\n record.job_id = record.jobId;\n delete record.jobId;\n }\n if (record.dueDate !== undefined) {\n record.due_date = record.dueDate;\n delete record.dueDate;\n }\n if (record.costPrice !== undefined) {\n record.cost_price = record.costPrice;\n delete record.costPrice;\n }\n if (record.unitPrice !== undefined) {\n record.unit_price = record.unitPrice;\n delete record.unitPrice;\n }\n if (record.reorderLevel !== undefined) {\n record.reorder_level = record.reorderLevel;\n delete record.reorderLevel;\n }\n if (record.durationHours !== undefined) {\n record.duration_hours = record.durationHours;\n delete record.durationHours;\n }\n if (record.approvedBy !== undefined) {\n record.approved_by = record.approvedBy;\n delete record.approvedBy;\n }\n if (record.supplierId !== undefined) {\n record.supplier_id = record.supplierId;\n delete record.supplierId;\n }\n if (record.supplierName !== undefined) {\n record.supplier_name = record.supplierName;\n delete record.supplierName;\n }\n if (record.templateId !== undefined) {\n record.template_id = record.templateId;\n delete record.templateId;\n }\n if (record.filePath !== undefined) {\n record.file_path = record.filePath;\n delete record.filePath;\n }\n if (record.createdAt !== undefined) {\n record.created_at = record.createdAt;\n delete record.createdAt;\n }\n if (record.updatedAt !== undefined) {\n record.updated_at = record.updatedAt;\n delete record.updatedAt;\n }\n\n return record;\n }\n\n // Legacy compatibility / No-op migration call\n migrateNumberFields() {}\n\n // ── Local-First Core API Operations ────────────────────────────────────────\n\n getAll(collection) {\n return this.cache[collection] || [];\n }\n\n getById(collection, id) {\n const items = this.getAll(collection);\n return items.find(item => item.id === id) || null;\n }\n\n // Pushes write changes asynchronously to Supabase while updating local cache synchronously\n async create(collection, item) {\n if (!this.companyId) return item;\n\n item.id = item.id || this.generateId();\n if (collection === 'leads' && !item.number) {\n item.number = 'LD-' + Date.now().toString().slice(-5);\n }\n if (collection === 'notifications' && !item.number) {\n item.number = 'NT-' + Date.now().toString().slice(-5);\n }\n item.createdAt = item.createdAt || new Date().toISOString();\n item.updatedAt = new Date().toISOString();\n item.companyId = this.companyId;\n\n // 1. Update memory cache immediately for responsiveness\n const items = [...this.cache[collection]];\n items.push(item);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Perform DB write in background\n const dbPayload = this.denormalizeRecord(item);\n const table = TABLE_MAP[collection];\n if (table) {\n // Profiles are keyed to auth.users, and userTypes requires specific IDs\n const { error } = await supabase.from(table).insert([dbPayload]);\n if (error) console.error(`Error saving new record to ${table}:`, error);\n }\n\n return item;\n }\n\n async update(collection, id, updates) {\n if (!this.companyId) return null;\n\n const items = [...this.cache[collection]];\n const index = items.findIndex(item => item.id === id);\n if (index === -1) return null;\n\n const previous = items[index];\n const updated = { ...previous, ...updates, updatedAt: new Date().toISOString() };\n \n // 1. Write to local cache synchronously\n items[index] = updated;\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Trigger local maintenance plans / assets update engines if completing a job\n if (collection === 'jobs' && updates.status === 'Completed' && previous.status !== 'Completed') {\n this.handleJobCompletionSideEffects(updated, previous);\n }\n\n // 3. Write updates asynchronously to Supabase\n const dbPayload = this.denormalizeRecord(updated);\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase\n .from(table)\n .update(dbPayload)\n .eq('id', id);\n if (error) console.error(`Error updating record in ${table}:`, error);\n }\n\n return updated;\n }\n\n async delete(collection, id) {\n if (!this.companyId) return;\n\n // 1. Update memory cache\n const items = this.cache[collection].filter(item => item.id !== id);\n this.cache[collection] = items;\n this.emit(collection, items);\n\n // 2. Write to Supabase asynchronously\n const table = TABLE_MAP[collection];\n if (table) {\n const { error } = await supabase\n .from(table)\n .delete()\n .eq('id', id);\n if (error) console.error(`Error deleting record from ${table}:`, error);\n }\n }\n\n // Handles completing job logs and maintenance intervals automatically\n async handleJobCompletionSideEffects(job, previousJob) {\n if (job.assetId) {\n const asset = this.getById('assets', job.assetId);\n if (asset) {\n const logs = asset.logs || [];\n const logText = `Completed Maintenance Job #${job.number} - ${job.title}`;\n const hasLog = logs.some(l => l.notes === logText);\n\n if (!hasLog) {\n logs.push({\n id: 'log_' + Date.now() + Math.random().toString(36).substr(2, 5),\n type: 'Service',\n date: new Date().toISOString().split('T')[0],\n meter: asset.currentMeter || 0,\n cost: (job.laborCost || 0) + (job.materialCost || 0),\n notes: logText,\n technicianName: job.technicianName || 'Unassigned',\n jobNumber: job.number\n });\n this.update('assets', asset.id, { logs });\n }\n\n const planIds = [job.maintenancePlanId, ...(job.mergedPlanIds || [])].filter(Boolean);\n if (planIds.length > 0) {\n planIds.forEach(pid => {\n const plan = this.getById('maintenancePlans', pid);\n if (plan) {\n if (plan.triggerType === 'Calendar') {\n const compDate = new Date();\n if (plan.frequency === 'Weekly') compDate.setDate(compDate.getDate() + 7);\n else if (plan.frequency === 'Monthly') compDate.setMonth(compDate.getMonth() + 1);\n else if (plan.frequency === 'Quarterly') compDate.setMonth(compDate.getMonth() + 3);\n else if (plan.frequency === 'Semi-Annually') compDate.setMonth(compDate.getMonth() + 6);\n else if (plan.frequency === 'Annually') compDate.setFullYear(compDate.getFullYear() + 1);\n \n this.update('maintenancePlans', plan.id, {\n nextServiceDate: compDate.toISOString().split('T')[0]\n });\n } else if (plan.triggerType === 'Meter') {\n this.update('maintenancePlans', plan.id, {\n lastTriggeredMeter: parseFloat(asset.currentMeter || 0)\n });\n }\n }\n });\n }\n }\n }\n }\n\n generateId() {\n return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);\n }\n\n // ── Company Settings API ───────────────────────────────────────────────────\n\n getSettings() {\n const defaultSettings = {\n name: 'Apex Power Services',\n abn: '51 234 567 890',\n phone: '(02) 6882 4400',\n domain: 'apexpowerservices.com.au',\n email: 'admin@apexpowerservices.com.au',\n address: '14 Yarrandale Rd, Dubbo NSW 2830',\n markupPercent: 20,\n materialMarkup: {\n defaultPercent: 30,\n minMarkupAmount: 5.00,\n useTiers: true,\n tiers: [\n { upTo: 50, percent: 60 },\n { upTo: 200, percent: 45 },\n { upTo: 1000, percent: 30 },\n { upTo: null, percent: 15 }\n ]\n },\n materialCategories: ['Consumables', 'Electrical', 'Plumbing', 'HVAC Parts', 'Fixings', 'General'],\n laborRates: [\n { id: 'rate_1', name: 'Standard Rate', rate: 85.00, description: 'Normal business hours Mon–Fri', overtimeMultiplier: 1.0, minCallOutFee: 0, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33], isDefault: true },\n { id: 'rate_2', name: 'After Hours Rate', rate: 127.50, description: 'Evenings and early mornings', overtimeMultiplier: 1.5, minCallOutFee: 45, applicableDays: ['Mon','Tue','Wed','Thu','Fri'], activeHours: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,34,35,36,37,38,39,40,41,42,43,44,45,46,47], isDefault: false },\n { id: 'rate_3', name: 'Saturday Rate', rate: 127.50, description: 'Saturday work', overtimeMultiplier: 1.5, minCallOutFee: 65, applicableDays: ['Sat'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_4', name: 'Sunday Rate', rate: 170.00, description: 'Sunday and public holidays', overtimeMultiplier: 2.0, minCallOutFee: 85, applicableDays: ['Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n { id: 'rate_5', name: 'Emergency Rate', rate: 195.00, description: 'Urgent call-outs any day', overtimeMultiplier: 2.0, minCallOutFee: 120, applicableDays: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'], activeHours: Array.from({length:48}, (_,i)=>i), isDefault: false },\n ],\n documentTheme: {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n }\n };\n\n if (this.companySettings) {\n const merged = { ...defaultSettings, ...this.companySettings };\n // Ensure sub-objects merge safely\n merged.documentTheme = { ...defaultSettings.documentTheme, ...this.companySettings.documentTheme };\n return merged;\n }\n return defaultSettings;\n }\n\n async saveSettings(settings) {\n if (!this.companyId) return;\n\n this.companySettings = settings;\n this.emit('settings', settings);\n\n // Save settings directly into the company JSONB column\n const { error } = await supabase\n .from('companies')\n .update({ settings })\n .eq('id', this.companyId);\n\n if (error) console.error('Error saving company settings to Supabase:', error);\n }\n\n // ── In-Memory Event Emitter ────────────────────────────────────────────────\n\n on(event, callback) {\n if (!this.listeners[event]) this.listeners[event] = [];\n this.listeners[event].push(callback);\n }\n\n off(event, callback) {\n if (!this.listeners[event]) return;\n this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);\n }\n\n emit(event, data) {\n if (this.listeners[event]) {\n this.listeners[event].forEach(cb => cb(data));\n }\n }\n\n // ── Database Seeding operations (if tables are empty) ──────────────────────\n\n isSeeded() {\n if (this.companyId) return true;\n return localStorage.getItem('simpro__seeded') === 'true';\n }\n\n save(collection, items) {\n this.cache[collection] = items;\n this.emit(collection, items);\n \n // If not running in cloud mode, fall back to localStorage to support the offline demo users list\n if (!this.companyId) {\n localStorage.setItem('simpro_' + collection, JSON.stringify(items));\n }\n }\n\n markSeeded() {\n localStorage.setItem('simpro__seeded', 'true');\n }\n\n async seedFormTemplates() {\n if (!this.companyId) return;\n\n const templates = prebuiltForms.map(tmpl => ({\n ...tmpl,\n company_id: this.companyId\n }));\n\n this.cache.formTemplates = templates;\n this.emit('formTemplates', templates);\n\n const dbPayload = templates.map(t => this.denormalizeRecord(t));\n const { error } = await supabase.from('form_templates').insert(dbPayload);\n if (error) console.error('Error seeding form templates:', error);\n }\n\n clearAll() {\n this.clearSync();\n if (!this.companyId) {\n Object.keys(TABLE_MAP).forEach(col => {\n localStorage.removeItem('simpro_' + col);\n });\n localStorage.removeItem('simpro__seeded');\n }\n }\n}\n\nexport const store = new DataStore();\n"}}, "src/pages/Settings.js": {"0227a0cd7bffdad9acb2241451005b35f9985c7c": {"t": 1785745556.367586, "n": 285192, "h": "ebadd18b53f9dcbf", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData, seedData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\nimport { FLAGS } from '../utils/flags.js';\nimport { applyTheme, THEMES } from '../utils/theme.js';\nimport { storageGet, storageSet } from '../utils/tauriStore.js';\n\n// Compress uploaded images using Canvas to avoid huge Base64 data payloads\nfunction compressImage(dataUrl, maxWidth, maxHeight) {\n return new Promise((resolve) => {\n if (!dataUrl) {\n resolve(dataUrl);\n return;\n }\n // SVG vectors are already lightweight; do not compress them\n if (dataUrl.startsWith('data:image/svg+xml')) {\n resolve(dataUrl);\n return;\n }\n const img = new Image();\n img.onload = () => {\n let width = img.width;\n let height = img.height;\n\n if (width > height) {\n if (width > maxWidth) {\n height = Math.round((height * maxWidth) / width);\n width = maxWidth;\n }\n } else {\n if (height > maxHeight) {\n width = Math.round((width * maxHeight) / height);\n height = maxHeight;\n }\n }\n\n const canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n\n const ctx = canvas.getContext('2d');\n ctx.drawImage(img, 0, 0, width, height);\n\n const isPng = dataUrl.includes('image/png');\n const format = isPng ? 'image/png' : 'image/jpeg';\n const quality = isPng ? undefined : 0.85;\n\n resolve(canvas.toDataURL(format, quality));\n };\n img.onerror = () => {\n resolve(dataUrl);\n };\n img.src = dataUrl;\n });\n}\n\n// Helper to hash password using SHA-256 Web Crypto API\nasync function hashPassword(password) {\n const msgBuffer = new TextEncoder().encode(password);\n const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);\n const hashArray = Array.from(new Uint8Array(hashBuffer));\n return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');\n}\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n const localDeploymentType = settings.localDeploymentType || 'single_user';\n\n const isUsersDisabled = isLocalMode && localDeploymentType === 'single_user';\n const isPortalDisabled = isLocalMode;\n const isFolderSyncDisabled = !isLocalMode;\n\n if (isUsersDisabled && activeTab === 'users') {\n activeTab = 'company';\n }\n if (isPortalDisabled && activeTab === 'portal') {\n activeTab = 'company';\n }\n if (isPortalDisabled && activeTab === 'portal_contractor') {\n activeTab = 'company';\n }\n if (isFolderSyncDisabled && activeTab === 'folder_sync') {\n activeTab = 'company';\n }\n \n const isAIAssistantDisabled = isLocalMode;\n if (isAIAssistantDisabled && activeTab === 'ai_assistant') {\n activeTab = 'company';\n }\n\n function getCategoryForTab(tab) {\n if (['company', 'portal', 'portal_contractor', 'folder_sync', 'ai_assistant', 'system'].includes(tab)) return 'general';\n if (['templates_forms', 'invoices_quotes', 'payments'].includes(tab)) return 'workflow';\n if (['users', 'suppliers'].includes(tab)) return 'people';\n if (['materials', 'cost_centers', 'tax'].includes(tab)) return 'resources';\n return 'general';\n }\n\n let activeCategory = getCategoryForTab(activeTab);\n\n const openMigrationModal = () => {\n const modalContent = document.createElement('div');\n modalContent.innerHTML = `\n <form id=\"convert-cloud-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Cloud Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a local/offline account. All data will be migrated to the secure cloud database.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"migrate-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"Company Name\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"migrate-admin-name\" required placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Phone Number</label>\n <input class=\"form-input\" id=\"migrate-admin-phone\" required placeholder=\"e.g. 0412 345 678\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Email Address (Username)</label>\n <input class=\"form-input\" type=\"email\" id=\"migrate-admin-email\" required placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Password</label>\n <input class=\"form-input\" type=\"password\" id=\"migrate-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"migration-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"migration-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-migrate-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-migrate-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" id=\"submit-icon\" style=\"font-size:18px;\">cloud_done</span>\n <span id=\"submit-text\">Register & Start Migration</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: 'Register & Migrate to Cloud',\n content: modalContent,\n size: 'modal-md'\n });\n\n modalContent.querySelector('#btn-migrate-cancel').addEventListener('click', close);\n\n const form = modalContent.querySelector('#convert-cloud-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n\n const errorEl = modalContent.querySelector('#migration-error');\n const errorTextEl = modalContent.querySelector('#migration-error-text');\n const submitBtn = modalContent.querySelector('#btn-migrate-submit');\n const cancelBtn = modalContent.querySelector('#btn-migrate-cancel');\n const submitText = modalContent.querySelector('#submit-text');\n const submitIcon = modalContent.querySelector('#submit-icon');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n submitText.textContent = 'Migrating to Cloud...';\n submitIcon.className = 'material-icons-outlined spinner';\n submitIcon.textContent = 'sync';\n submitIcon.style.animation = 'spin 1s linear infinite';\n\n const companyName = modalContent.querySelector('#migrate-company-name').value.trim();\n const adminName = modalContent.querySelector('#migrate-admin-name').value.trim();\n const adminPhone = modalContent.querySelector('#migrate-admin-phone').value.trim();\n const email = modalContent.querySelector('#migrate-admin-email').value.trim();\n const password = modalContent.querySelector('#migrate-admin-password').value;\n\n try {\n const settings = store.getSettings();\n settings.name = companyName;\n await store.saveSettings(settings);\n\n const { supabase } = await import('../utils/supabase.js');\n\n const { data: authData, error: authErr } = await supabase.auth.signUp({\n email,\n password,\n options: {\n data: {\n name: adminName,\n phone: adminPhone\n }\n }\n });\n if (authErr) throw authErr;\n\n if (!authData.user) {\n throw new Error('Verification required or signup was blocked. Check your email inbox.');\n }\n\n const { data: companyId, error: rpcError } = await supabase.rpc('create_company_and_admin', {\n user_id: authData.user.id,\n company_name: companyName,\n admin_name: adminName,\n admin_phone: adminPhone\n });\n if (rpcError) throw rpcError;\n\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n await store.migrateLocalToCloud(companyId, authData.user.id);\n\n if (activeAccountId) {\n const localAccountsKey = 'relay_accounts';\n let localAccounts = [];\n try {\n const stored = localStorage.getItem(localAccountsKey);\n if (stored) {\n localAccounts = JSON.parse(stored);\n }\n } catch (e) {\n console.error('Error reading local accounts:', e);\n }\n localAccounts = localAccounts.filter(a => a.id !== activeAccountId);\n localStorage.setItem(localAccountsKey, JSON.stringify(localAccounts));\n\n store.deleteLocalAccountData(activeAccountId);\n }\n\n const { data: profile, error: profileErr } = await supabase\n .from('profiles')\n .select('*')\n .eq('id', authData.user.id)\n .single();\n if (profileErr) throw profileErr;\n\n const user = {\n id: profile.id,\n companyId: profile.company_id,\n name: profile.name,\n role: profile.role,\n userTypeName: 'Admin',\n userTypeId: `${profile.company_id}_ut_admin`,\n color: profile.color || '#FF5C00',\n theme: 'light'\n };\n\n localStorage.setItem('currentUser', JSON.stringify(user));\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Migration completed successfully! Redirecting...', 'success');\n close();\n\n setTimeout(() => {\n window.location.hash = '#/';\n window.location.reload();\n }, 1500);\n\n } catch (err) {\n console.error('Migration failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during migration.';\n errorEl.style.display = 'flex';\n\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n submitText.textContent = 'Register & Start Migration';\n submitIcon.className = 'material-icons-outlined';\n submitIcon.textContent = 'cloud_done';\n submitIcon.style.animation = '';\n }\n });\n };\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n const categories = [\n {\n id: 'general',\n label: 'General',\n icon: 'settings',\n tabs: [\n { id: 'company', label: 'Company Profile' },\n { id: 'portal', label: 'Customer Portal', disabled: isPortalDisabled, tooltip: 'Requires Cloud Account' },\n { id: 'portal_contractor', label: 'Contractor Portal', disabled: isPortalDisabled, tooltip: 'Requires Cloud Account' },\n { id: 'folder_sync', label: 'Folder Sync', disabled: isFolderSyncDisabled, tooltip: 'Requires Local Folder Storage' },\n { id: 'api_keys', label: 'Local System Configuration' },\n { id: 'system', label: 'System Options' }\n ]\n },\n {\n id: 'workflow',\n label: 'Workflow',\n icon: 'account_tree',\n tabs: [\n { id: 'templates_forms', label: 'Templates & Forms' },\n { id: 'invoices_quotes', label: 'Quotes & Invoices' },\n // v1.3 #3 — dark until FLAGS.payments flips on for launch\n ...(FLAGS.payments ? [{ id: 'payments', label: 'Payments' }] : [])\n ]\n },\n {\n id: 'people',\n label: 'People',\n icon: 'groups',\n tabs: [\n { id: 'users', label: 'Users & Permissions', disabled: isUsersDisabled, tooltip: 'Requires Cloud Account or Local Server' },\n { id: 'suppliers', label: 'Suppliers Configuration' }\n ]\n },\n {\n id: 'resources',\n label: 'Resources',\n icon: 'widgets',\n tabs: [\n { id: 'materials', label: 'Materials & Kits' },\n { id: 'cost_centers', label: 'Cost Centers & Xero' },\n { id: 'tax', label: 'Tax & Labor Rates' }\n ]\n }\n ];\n\n function render() {\n const categoryTabsHtml = categories.map(cat => {\n const isActive = activeCategory === cat.id;\n return `\n <button class=\"settings-category-btn ${isActive ? 'active' : ''}\" data-category=\"${cat.id}\">\n <span class=\"material-icons-outlined\">${cat.icon}</span>\n <span>${cat.label}</span>\n </button>\n `;\n }).join('');\n\n const activeCatData = categories.find(cat => cat.id === activeCategory) || categories[0];\n const subTabsHtml = activeCatData.tabs.map(tab => {\n const isActive = activeTab === tab.id;\n const isDisabled = tab.disabled;\n const tooltipAttr = isDisabled ? `data-tooltip=\"${tab.tooltip}\" data-tooltip-pos=\"top\"` : '';\n return `\n <button class=\"settings-tab-btn ${isActive ? 'active' : ''} ${isDisabled ? 'disabled-local' : ''}\" data-tab=\"${tab.id}\" ${tooltipAttr}>\n ${tab.label}\n </button>\n `;\n }).join('');\n\n container.innerHTML = `\n <style>\n .settings-nav-categories {\n display: flex;\n gap: 8px;\n border-bottom: 2px solid var(--border-color);\n padding-bottom: 12px;\n margin-bottom: 20px;\n }\n .settings-category-btn {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 10px 20px;\n font-size: 15px;\n font-weight: 600;\n color: var(--text-secondary);\n background: transparent;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n .settings-category-btn .material-icons-outlined {\n font-size: 20px;\n transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n .settings-category-btn:hover {\n color: var(--text-primary);\n background: var(--bg-color);\n }\n .settings-category-btn:hover .material-icons-outlined {\n transform: scale(1.08);\n }\n .settings-category-btn.active {\n color: white;\n background: var(--color-primary);\n box-shadow: 0 4px 14px rgba(255, 92, 0, 0.25);\n transform: translateY(-1px);\n }\n \n .settings-nav-tabs {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-bottom: 24px;\n background: var(--bg-color);\n padding: 6px;\n border-radius: 10px;\n border: 1px solid var(--border-color);\n animation: subtabs-slide-in 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);\n }\n @keyframes subtabs-slide-in {\n from {\n opacity: 0;\n transform: translateX(-10px);\n }\n to {\n opacity: 1;\n transform: translateX(0);\n }\n }\n .settings-tab-btn {\n padding: 8px 16px;\n font-size: var(--font-size-sm);\n font-weight: 500;\n color: var(--text-secondary);\n background: transparent;\n border: none;\n border-radius: 20px;\n cursor: pointer;\n transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n .settings-tab-btn:hover:not(.disabled-local) {\n color: var(--text-primary);\n background: rgba(128, 128, 128, 0.08);\n }\n .settings-tab-btn.active {\n color: var(--color-primary);\n background: var(--content-bg);\n font-weight: 600;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);\n transform: scale(1.02);\n }\n \n #settings-content {\n animation: settings-fade-in 0.28s cubic-bezier(0.4, 0, 0.2, 1);\n }\n @keyframes settings-fade-in {\n from {\n opacity: 0;\n transform: translateY(6px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n }\n </style>\n\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"settings-nav-categories\">\n ${categoryTabsHtml}\n </div>\n\n <div class=\"settings-nav-tabs\">\n ${subTabsHtml}\n </div>\n\n <div id=\"settings-content\" style=\"padding-top:var(--space-sm)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.settings-category-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n const catId = btn.dataset.category;\n if (activeCategory === catId) return;\n\n activeCategory = catId;\n const catData = categories.find(c => c.id === catId);\n const firstActiveTab = catData.tabs.find(t => !t.disabled) || catData.tabs[0];\n activeTab = firstActiveTab.id;\n\n render();\n });\n });\n\n container.querySelectorAll('.settings-tab-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n if (btn.classList.contains('disabled-local')) return;\n activeTab = btn.dataset.tab;\n render();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'suppliers') {\n renderSuppliersSettings(tc);\n return;\n }\n\n if (activeTab === 'folder_sync') {\n renderFolderSyncTab(tc);\n return;\n }\n\n if (activeTab === 'api_keys') {\n renderApiKeysTab(tc);\n return;\n }\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'payments') {\n renderPaymentsTab(tc);\n return;\n }\n\n if (activeTab === 'cost_centers') {\n renderCostCentersTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n\n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Standard Logo (Large) using Canvas to ~600x300 max bounding box\n compressImage(re.target.result, 600, 300).then((compressed) => {\n pendingLogo = compressed;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Small/Shrunk Logo using Canvas to ~200x100 max bounding box\n compressImage(re.target.result, 200, 100).then((compressed) => {\n pendingLogoSmall = compressed;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n\n\n tc.querySelector('#btn-save-company').addEventListener('click', async () => {\n const saveBtn = tc.querySelector('#btn-save-company');\n const originalHtml = saveBtn.innerHTML;\n saveBtn.disabled = true;\n saveBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving Changes...';\n\n try {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n await store.saveSettings(settings);\n showToast('Company information saved successfully to database', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n render();\n } catch (err) {\n console.error('Error saving company profile settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n saveBtn.disabled = false;\n saveBtn.innerHTML = originalHtml;\n }\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n const pendingResets = store.getAll('passwordResetRequests') || [];\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div style=\"background:rgba(59, 130, 246, 0.1); border-left:4px solid #3b82f6; padding:12px 16px; margin-bottom:var(--space-md); border-radius:4px; font-size:13px; color:#f8fafc; display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:#3b82f6; font-size:18px;\">info</span>\n <span>Your company login code is <strong style=\"color:#60a5fa\">${companySlug}</strong>. Technicians log in using <strong style=\"color:#60a5fa\">username@${companySlug}</strong>.</span>\n </span>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Username</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n ${ut?.id !== 'ut_admin' ? `\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n ` : ''}\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\">\n <h4 style=\"margin:0\">Pending Password Reset Requests</h4>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Username/Email</th>\n <th>Requested On</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:24px\">No pending reset requests</td></tr>' : ''}\n ${pendingResets.filter(r => r.status === 'pending').map(r => {\n const tech = techs.find(t => t.id === r.technician_id) || {};\n const requestedAt = r.requested_at ? new Date(r.requested_at).toLocaleString() : 'Unknown';\n return `\n <tr>\n <td class=\"font-medium\">${tech.name || 'Unknown'}</td>\n <td class=\"text-secondary\">${r.employee_id || tech.username || ''}</td>\n <td class=\"text-tertiary\">${requestedAt}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-approve-reset\" data-id=\"${r.id}\" data-tech-id=\"${tech.id}\">Approve &amp; Reset</button>\n <button class=\"btn btn-sm btn-ghost text-danger btn-deny-reset\" data-id=\"${r.id}\">Deny</button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n ${isLocalMode ? '' : `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `}\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => {\n openUserModal();\n });\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-approve-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const techId = e.currentTarget.dataset.techId;\n const t = store.getById('technicians', techId);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <p>Approve password reset request for <strong>${t.name}</strong>? Please enter their new password below:</p>\n <div class=\"form-group\" style=\"margin-top:12px\">\n <label class=\"form-label\">New Password</label>\n <input type=\"password\" id=\"admin-reset-pwd-input\" class=\"form-input\" placeholder=\"Min. 6 characters\" minlength=\"6\" autofocus />\n </div>\n `;\n showModal({\n title: 'Approve Password Reset',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reset Password', className: 'btn-primary', onClick: c => {\n const pwdInput = document.getElementById('admin-reset-pwd-input');\n const newPwd = pwdInput ? pwdInput.value : '';\n if (!newPwd || newPwd.length < 6) {\n showToast('Password must be at least 6 characters.', 'error');\n return;\n }\n \n // Update technician password\n store.update('technicians', techId, { password: newPwd });\n \n // Approve the request\n store.update('passwordResetRequests', id, { status: 'approved', updated_at: new Date().toISOString() });\n \n showToast(`Password updated for ${t.name}`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-deny-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n store.update('passwordResetRequests', id, { status: 'denied', updated_at: new Date().toISOString() });\n showToast('Password reset request denied', 'info');\n renderContent();\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n let currentJobTypes = settings.jobTypes ? [...settings.jobTypes] : ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'];\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg)\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax Rates</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\" style=\"display:flex; flex-direction:column; gap:8px\">\n <label class=\"form-label\" style=\"display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:0\">\n <input type=\"checkbox\" id=\"tax-enabled\" style=\"width:16px; height:16px; margin:0\" ${settings.taxEnabled !== false ? 'checked' : ''} />\n Enable GST / Sales Tax\n </label>\n <div style=\"display:${settings.taxEnabled !== false ? 'flex' : 'none'}; align-items:center; gap:8px; margin-top:4px\" id=\"tax-rate-container\">\n <input class=\"form-input\" id=\"tax-rate\" type=\"number\" value=\"${settings.taxRate !== undefined ? settings.taxRate : 10}\" style=\"width:100px\" min=\"0\" max=\"100\" step=\"0.1\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" id=\"job-types-card\">\n <div class=\"card-header\"><h4>Job Types</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:12px\">Define the types of jobs your team performs (e.g. Electrical, Plumbing). These appear in job forms and rate mapping.</p>\n <div id=\"job-types-list\" style=\"display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px;\">\n ${currentJobTypes.map(type => `\n <span class=\"tag-pill tag-pill-active\" style=\"display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:100px; font-weight:500; font-size:13px; background:var(--color-primary-light); color:var(--color-primary-dark)\">\n <span>${escapeHTML(type)}</span>\n <span class=\"material-icons-outlined delete-job-type-btn\" data-type=\"${escapeHTML(type)}\" style=\"font-size:16px; cursor:pointer; opacity:0.7; transition:opacity 0.2s\">close</span>\n </span>\n `).join('')}\n </div>\n <div style=\"display:flex; gap:8px;\">\n <input class=\"form-input\" id=\"new-job-type-input\" type=\"text\" placeholder=\"e.g. Renovation\" style=\"flex:1\" />\n <button class=\"btn btn-secondary\" id=\"add-job-type-btn\" style=\"display:flex; align-items:center; gap:4px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${escapeHTML(rate.name)}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${escapeHTML(rate.description || '')}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => {\n const active = applicable.includes(day);\n return `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${active ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'};background:${active ? 'var(--color-primary-light)' : 'transparent'};color:${active ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `}).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // Toggle tax rate container visibility\n tc.querySelector('#tax-enabled')?.addEventListener('change', (e) => {\n const container = tc.querySelector('#tax-rate-container');\n if (container) {\n container.style.display = e.target.checked ? 'flex' : 'none';\n }\n });\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Job Types Management Event Listeners ----\n const addJobTypeBtn = tc.querySelector('#add-job-type-btn');\n const newJobTypeInput = tc.querySelector('#new-job-type-input');\n const jobTypesList = tc.querySelector('#job-types-list');\n\n function updateJobTypesUI() {\n jobTypesList.innerHTML = currentJobTypes.map(type => `\n <span class=\"tag-pill tag-pill-active\" style=\"display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:100px; font-weight:500; font-size:13px; background:var(--color-primary-light); color:var(--color-primary-dark)\">\n <span>${escapeHTML(type)}</span>\n <span class=\"material-icons-outlined delete-job-type-btn\" data-type=\"${escapeHTML(type)}\" style=\"font-size:16px; cursor:pointer; opacity:0.7; transition:opacity 0.2s\">close</span>\n </span>\n `).join('');\n }\n\n addJobTypeBtn?.addEventListener('click', () => {\n const val = newJobTypeInput.value.trim();\n if (!val) {\n showToast('Please enter a job type name', 'warning');\n return;\n }\n if (currentJobTypes.includes(val)) {\n showToast('This job type already exists', 'warning');\n return;\n }\n currentJobTypes.push(val);\n newJobTypeInput.value = '';\n updateJobTypesUI();\n });\n\n tc.querySelector('#job-types-card')?.addEventListener('click', (e) => {\n const btn = e.target.closest('.delete-job-type-btn');\n if (btn) {\n const typeToDelete = btn.dataset.type;\n currentJobTypes = currentJobTypes.filter(t => t !== typeToDelete);\n updateJobTypesUI();\n }\n });\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#save-tax-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const taxEnabled = tc.querySelector('#tax-enabled').checked;\n const taxRate = parseFloat(tc.querySelector('#tax-rate').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.taxEnabled = taxEnabled;\n settings.taxRate = taxRate;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n settings.jobTypes = currentJobTypes;\n\n await store.saveSettings(settings);\n showToast('Financial and Rate settings saved successfully', 'success');\n renderContent();\n } catch (err) {\n console.error('Error saving tax and rate settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-portal-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n await store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving customer portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'portal_contractor') {\n const s = store.getSettings();\n const portalEnabled = s.enableContractorPortal !== false;\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Contractor Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"contractor-portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('contractor-portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Contractor Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any subcontractor attempting to load their portal token will see an access deactivated notice.</span>\n </div>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-contractor-settings\" data-tooltip=\"Save subcontractor portal access rules\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Contractor Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-contractor-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-contractor-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableContractorPortal = tc.querySelector('#contractor-portal-enable').checked;\n\n await store.saveSettings(settings);\n showToast('Contractor portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving contractor portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'system') {\n\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n const currentTheme = (currentUser && currentUser.id) ? (currentUser.theme || localStorage.getItem(`simpro_theme_${currentUser.id}`) || 'light') : 'light';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Left Column -->\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg)\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n </p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n ${currentUser.role === 'admin' ? `\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed a complete demonstration dataset\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed Demonstration Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n <button class=\"btn btn-danger\" id=\"btn-delete-company\" data-tooltip=\"Permanently delete the entire company profile and all data\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; background:var(--color-danger); border-color:var(--color-danger); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span> Delete Company Profile\n </button>\n\n ${isLocalMode ? `\n <div style=\"margin-top:16px; padding-top:12px; border-top:1px dashed rgba(220, 38, 38, 0.2)\">\n <h6 style=\"color:var(--color-warning); margin-bottom:8px; font-weight:600; display:flex; align-items:center; gap:4px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">warning</span> Irreversible Profile Conversions\n </h6>\n <p style=\"font-size:11px; color:var(--text-secondary); margin-bottom:12px; line-height:1.3;\">\n Convert your deployment type. These transitions are permanent and one-way.\n </p>\n ${localDeploymentType === 'single_user' ? `\n <button class=\"btn\" id=\"btn-convert-multiuser\" style=\"width:100%; justify-content:center; margin-bottom:12px; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">lan</span> Convert to Multi-User Local Network Sync\n </button>\n ` : ''}\n <button class=\"btn\" id=\"btn-convert-cloud-action\" style=\"width:100%; justify-content:center; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">cloud_upload</span> Convert to Cloud Sync\n </button>\n </div>\n ` : ''}\n </div>\n ` : '<div style=\"font-size:12.5px; color:var(--text-tertiary)\">Administrative actions are restricted to company administrators.</div>'}\n </div>\n </div>\n </div>\n\n <!-- Local Data Backup -->\n ${!isLocalMode && currentUser.role === 'admin' ? `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary)\">backup</span>\n <h4 style=\"margin:0;\">Local Data Backup</h4>\n </div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:12px;\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm); line-height:1.4; margin:0;\">\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n </p>\n <div id=\"backup-status-container\"></div>\n </div>\n </div>\n ` : ''}\n </div>\n\n <!-- Right Column -->\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">System Color Theme</label>\n <select class=\"form-select\" id=\"system-theme-select\" style=\"width:100%\">\n ${Object.entries(THEMES).map(([key, val]) => `\n <option value=\"${key}\" ${currentTheme === key ? 'selected' : ''}>${val.name}</option>\n `).join('')}\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Select a color profile and visual background effect for your user interface.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', async (e) => {\n const select = e.target;\n select.disabled = true;\n try {\n const settings = store.getSettings();\n settings.tooltipPreference = select.value;\n await store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved successfully', 'success');\n } catch (err) {\n console.error('Error saving tooltip preference:', err);\n showToast('Failed to save preferences: ' + (err.message || err), 'error');\n } finally {\n select.disabled = false;\n }\n });\n\n tc.querySelector('#system-theme-select')?.addEventListener('change', (e) => {\n applyTheme(e.target.value, true);\n showToast('System theme updated successfully', 'success');\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a complete demonstration dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load a complete, realistic demonstration dataset representing an active trade business (5 customers, 5 quotes/jobs per customer, 5 assets per customer, 5 materials, 5 users/timesheets, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for testing and walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed Demonstration Data\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Demo Data\",\n className: \"btn-primary\",\n onClick: async (close) => {\n close();\n showToast('Seeding demonstration data...', 'info');\n await seedMinimalData();\n showToast('Demonstration data seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-delete-company')?.addEventListener('click', () => {\n // Modal Warning 1 of 2\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">warning</span>\n <span>You are about to permanently delete the entire company profile for <strong>${escapeHTML(store.getSettings().name || '')}</strong> and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).</span>\n </div>\n <p style=\"font-size:12.5px; color:var(--text-secondary); margin-bottom:12px\">\n This action is final, irreversible, and cannot be undone under any circumstances.\n </p>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Do you want to proceed to password confirmation?</p>\n `;\n\n showModal({\n title: \"Delete Company Profile - Step 1 of 2\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (closeModal) => closeModal()\n },\n {\n label: \"Proceed to Confirm\",\n className: \"btn-danger\",\n onClick: (closeModal) => {\n closeModal();\n \n // Modal Warning 2 of 2\n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <form id=\"delete-company-confirm-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <p style=\"margin-bottom:8px; font-weight:600; color:var(--color-danger)\">FINAL CONFIRMATION (2 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">gavel</span>\n <span>To authorize the permanent destruction of this company, you must enter the Administrator password.</span>\n </div>\n\n <div class=\"form-group\" style=\"margin-top:12px;\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Password</label>\n <input class=\"form-input\" type=\"password\" id=\"delete-confirm-password\" required placeholder=\"Enter admin password to proceed\" />\n </div>\n\n <div id=\"delete-confirm-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"delete-confirm-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-delete-abort\">Abort Deletion</button>\n <button type=\"submit\" class=\"btn btn-danger\" id=\"btn-delete-confirm-submit\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span>\n <span>Permanently Delete Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close: close2 } = showModal({\n title: \"⚠️ Confirm Company Deletion - Step 2 of 2\",\n content: content2,\n size: \"modal-md\"\n });\n\n content2.querySelector('#btn-delete-abort').addEventListener('click', close2);\n\n const form2 = content2.querySelector('#delete-company-confirm-form');\n form2.addEventListener('submit', async (ev) => {\n ev.preventDefault();\n\n const errorEl = content2.querySelector('#delete-confirm-error');\n const errorTextEl = content2.querySelector('#delete-confirm-error-text');\n const submitBtn = content2.querySelector('#btn-delete-confirm-submit');\n const abortBtn = content2.querySelector('#btn-delete-abort');\n const passwordInput = content2.querySelector('#delete-confirm-password').value;\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n abortBtn.disabled = true;\n const origSubmitText = submitBtn.innerHTML;\n submitBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Deleting...';\n\n try {\n let passwordVerified = false;\n\n if (isLocalMode) {\n if (localDeploymentType === 'single_user') {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acct = storedAccounts.find(a => a.id === activeAccountId);\n if (acct && acct.hasPassword) {\n const hashedInput = await hashPassword(passwordInput);\n if (hashedInput === acct.passwordHash) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n // No password set\n passwordVerified = true;\n }\n } else {\n // local_multiuser\n const techs = store.getAll('technicians') || [];\n const currentTech = techs.find(t => t.id === currentUser.id);\n const expectedPassword = currentTech ? currentTech.password : '123456';\n if (passwordInput === expectedPassword) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n }\n } else {\n // Cloud mode\n const { supabase } = await import('../utils/supabase.js');\n const { data: { user } } = await supabase.auth.getUser();\n if (user && user.email) {\n const { error } = await supabase.auth.signInWithPassword({\n email: user.email,\n password: passwordInput\n });\n if (!error) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n throw new Error('Could not retrieve logged-in cloud user credentials.');\n }\n }\n\n if (passwordVerified) {\n // 1. If cloud mode, delete the company row in Supabase\n if (!isLocalMode) {\n const { supabase } = await import('../utils/supabase.js');\n const { error: deleteErr } = await supabase.from('companies').delete().eq('id', store.companyId);\n if (deleteErr) {\n throw new Error('Failed to delete cloud company: ' + deleteErr.message);\n }\n }\n\n // 2. Trigger store.clearAll()\n store.clearAll();\n\n // 3. Clear local account launcher keys if local mode\n if (isLocalMode) {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const updatedAccounts = storedAccounts.filter(a => a.id !== activeAccountId);\n await storageSet('relay_accounts', updatedAccounts);\n }\n }\n\n // 4. Remove session user & active account\n localStorage.removeItem('currentUser');\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Company profile deleted successfully.', 'success');\n close2();\n\n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n } catch (err) {\n console.error('Company deletion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during verification.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n abortBtn.disabled = false;\n submitBtn.innerHTML = origSubmitText;\n }\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-convert-multiuser')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n\n const techs = store.getAll('technicians') || [];\n const companyId = store.companyId || '';\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const existingAdmin = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n const currentTechName = existingAdmin ? existingAdmin.name : (currentUser.name && currentUser.name !== 'Local Admin' ? currentUser.name : '');\n const currentTechUsernameOrEmail = existingAdmin ? (existingAdmin.email || existingAdmin.username || '') : '';\n\n content.innerHTML = `\n <form id=\"convert-multiuser-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Multi-User Local Network Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a single-user Local Admin profile.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"multiuser-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"e.g. Apex Power Services\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"multiuser-admin-name\" required value=\"${escapeHTML(currentTechName)}\" placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Email Address (Required)</label>\n <input class=\"form-input\" type=\"email\" id=\"multiuser-admin-email\" required value=\"${escapeHTML(currentTechUsernameOrEmail)}\" placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Admin Password</label>\n <input class=\"form-input\" type=\"password\" id=\"multiuser-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"multiuser-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"multiuser-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-multiuser-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-multiuser-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">lan</span>\n <span>Convert Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: \"Convert to Multi-User Local Sync\",\n content: content,\n size: \"modal-md\"\n });\n\n content.querySelector('#btn-multiuser-cancel').addEventListener('click', close);\n\n const form = content.querySelector('#convert-multiuser-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n const errorEl = content.querySelector('#multiuser-error');\n const errorTextEl = content.querySelector('#multiuser-error-text');\n const submitBtn = content.querySelector('#btn-multiuser-submit');\n const cancelBtn = content.querySelector('#btn-multiuser-cancel');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n\n const businessName = content.querySelector('#multiuser-company-name').value.trim();\n const adminName = content.querySelector('#multiuser-admin-name').value.trim();\n const adminEmail = content.querySelector('#multiuser-admin-email').value.trim();\n const adminPassword = content.querySelector('#multiuser-admin-password').value;\n\n try {\n if (!businessName || !adminName || !adminEmail || !adminPassword) {\n throw new Error('All fields are required.');\n }\n if (adminPassword.length < 6) {\n throw new Error('Password must be at least 6 characters.');\n }\n\n // 1. Update settings\n const settings = store.getSettings();\n settings.name = businessName;\n settings.localDeploymentType = 'multi_user';\n await store.saveSettings(settings);\n\n // 2. Update launcher accounts businessName\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acctIndex = storedAccounts.findIndex(a => a.id === activeAccountId);\n if (acctIndex !== -1) {\n storedAccounts[acctIndex].businessName = businessName;\n await storageSet('relay_accounts', storedAccounts);\n }\n }\n\n // 3. Create or update admin user in technicians\n const companyId = store.companyId;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const techs = store.getAll('technicians') || [];\n let adminTech = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n if (!adminTech) {\n adminTech = {\n id: `${companyId}_tech_admin_created`,\n role: 'Admin',\n color: '#FF5C00',\n userTypeId: adminTypeId,\n payRate: 100.00,\n phone: ''\n };\n techs.push(adminTech);\n }\n\n adminTech.name = adminName;\n adminTech.username = adminEmail.split('@')[0];\n adminTech.email = adminEmail;\n adminTech.password = adminPassword;\n\n store.save('technicians', techs);\n\n // 4. Update currentUser session in localStorage\n const localUser = {\n id: adminTech.id,\n companyId: companyId,\n name: adminTech.name,\n role: 'admin',\n userTypeName: 'Admin',\n userTypeId: adminTech.userTypeId,\n color: adminTech.color || '#FF5C00',\n theme: 'light'\n };\n localStorage.setItem('currentUser', JSON.stringify(localUser));\n\n showToast('Converted to Multi-User Local Network Sync. Reloading...', 'success');\n close();\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n setTimeout(() => window.location.reload(), 1200);\n } catch (err) {\n console.error('Conversion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during conversion.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n }\n });\n });\n\n tc.querySelector('#btn-convert-cloud-action')?.addEventListener('click', () => {\n openMigrationModal();\n });\n\n const renderBackupStatus = () => {\n const bsc = tc.querySelector('#backup-status-container');\n if (!bsc) return;\n\n const hasHandle = !!store.backupDirHandle;\n const isPermissionGranted = store.backupDirPermissionGranted;\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const formattedLastBackup = lastBackup ? new Date(lastBackup).toLocaleString() : 'Never';\n const isSupported = typeof window !== 'undefined' && window.showDirectoryPicker;\n\n if (!isSupported) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-danger); line-height:1.4;\">\n <strong style=\"display:block; margin-bottom:4px;\">Browser Local Folder Access Unsupported</strong>\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n </div>\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:12px; border-radius:6px; margin-bottom:12px; color:var(--text-secondary); display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary);\">folder_off</span>\n <div style=\"font-size:12.5px;\">No backup folder configured.</div>\n </div>\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">folder</span> Choose Backup Folder...\n </button>\n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-warning); line-height:1.4; margin-bottom:12px;\">\n <strong>Permission Required</strong><br/>\n Access permission to <strong>${escapeHTML(store.backupDirHandle.name)}</strong> has expired. Please re-authorize.\n </div>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-warning\" id=\"btn-backup-auth\" style=\"flex:1; justify-content:center;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize & Backup\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n `;\n } else {\n bsc.innerHTML = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-success); line-height:1.4; margin-bottom:12px;\">\n <strong>Backup Configured</strong><br/>\n Folder: <strong>${escapeHTML(store.backupDirHandle.name)}</strong><br/>\n Last Backup: <strong>${formattedLastBackup}</strong>\n </div>\n <div style=\"display:flex; flex-direction:column; gap:8px;\">\n <button class=\"btn btn-primary\" id=\"btn-backup-now\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">backup</span> Backup Now\n </button>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"flex:1; justify-content:center; border:1px solid var(--border-color);\">\n <span class=\"material-icons-outlined\">folder</span> Change Folder...\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n </div>\n `;\n }\n\n // Attach event listeners for backup buttons inside container\n bsc.querySelector('#btn-backup-pick')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setBackupDirectory(handle);\n showToast('Backup directory selected successfully.', 'success');\n showToast('Running initial backup...', 'info');\n await store.backupToFolder(handle);\n showToast('Backup completed successfully!', 'success');\n renderBackupStatus();\n } catch (err) {\n console.error('Failed to select backup directory:', err);\n if (err.name !== 'AbortError') {\n showToast('Failed to configure backup: ' + err.message, 'error');\n }\n }\n });\n\n bsc.querySelector('#btn-backup-auth')?.addEventListener('click', async () => {\n const granted = await store.verifyBackupDirPermission(true);\n if (granted) {\n showToast('Permission re-authorized. Backing up...', 'info');\n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n showToast('Backup failed: ' + err.message, 'error');\n }\n } else {\n showToast('Failed to acquire write permission.', 'error');\n }\n renderBackupStatus();\n });\n\n bsc.querySelector('#btn-backup-now')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Backing up...';\n \n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Backup failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n renderBackupStatus();\n }\n });\n\n bsc.querySelector('#btn-backup-disconnect')?.addEventListener('click', async () => {\n await store.setBackupDirectory(null);\n showToast('Backup folder disconnected.', 'info');\n renderBackupStatus();\n });\n };\n\n if (!isLocalMode && currentUser.role === 'admin') {\n renderBackupStatus();\n }\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', username: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Username</label>\n <input class=\"form-input\" id=\"u-username\" value=\"${t.username || (t.email ? t.email.split('@')[0] : '')}\" ${editId ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''} placeholder=\"e.g. joshua\" />\n ${!editId ? `\n <div style=\"font-size: 11px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500;\">\n Company login code is: <strong style=\"color: var(--color-primary)\">${companySlug}</strong>. User will log in with <strong style=\"color: var(--color-primary)\">username@${companySlug}</strong>\n </div>\n ` : ''}\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">${editId ? 'Reset Password (leave blank to keep current)' : 'Temporary Password (assigned for worker login)'}</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"${editId ? '••••••••' : 'Min. 6 characters'}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\" ${(t.userTypeId === 'ut_admin' || (t.userTypeId && t.userTypeId.endsWith('_ut_admin'))) ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''}>\n <option value=\"\">-- Select --</option>\n ${userTypes\n .filter(ut => (!ut.id.endsWith('_ut_admin') && ut.id !== 'ut_admin') || t.userTypeId === ut.id)\n .map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value.trim();\n const username = document.getElementById('u-username').value.trim();\n const role = document.getElementById('u-role').value.trim();\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = document.getElementById('u-password')?.value || '';\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!username) { showToast('Username required', 'error'); return; }\n if (username.includes('@')) { showToast('Username cannot contain @ symbol', 'error'); return; }\n \n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (password && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n const updates = { name, username, role, userTypeId, color, payRate };\n if (password) {\n updates.password = password;\n }\n\n if (editId) {\n await store.update('technicians', editId, updates);\n } else {\n await store.create('technicians', updates);\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = async () => {\n const btn = tc.querySelector('#btn-save-materials');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n await store.saveSettings(updatedSettings);\n showToast('Material settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving material settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderPaymentsTab(tc) {\n const settings = store.getSettings() || {};\n const pay = settings.payments || {};\n const isCloud = !!(store.companyId && !String(store.companyId).startsWith('acct_'));\n\n if (!isCloud) {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:760px\">\n <div class=\"card-header\"><h4>Online Payments (Stripe)</h4></div>\n <div class=\"card-body\">\n <p style=\"color:var(--text-secondary);\">Online card payments are a cloud feature. Upgrade to a cloud account to let customers pay invoices online by card.</p>\n </div>\n </div>`;\n return;\n }\n\n const enabledFor = pay.enabledFor || {};\n const currencies = ['AUD', 'USD', 'NZD', 'GBP', 'EUR'];\n const cur = (pay.currency || 'AUD').toUpperCase();\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:820px\">\n <div class=\"card-header\"><h4>Online Payments (Stripe)</h4></div>\n <div class=\"card-body\">\n <p style=\"color:var(--text-secondary);margin-top:0;\">\n Let customers pay invoices by card via a secure Stripe checkout link. Generate a link from any sent invoice; it's marked Paid automatically once payment clears.\n </p>\n\n <div style=\"background:var(--content-bg);border:1px solid var(--border-color);border-radius:8px;padding:14px 16px;margin:14px 0;\">\n <div style=\"font-weight:600;font-size:13px;margin-bottom:8px;\">One-time setup</div>\n <ol style=\"margin:0;padding-left:18px;font-size:12px;color:var(--text-secondary);line-height:1.7;\">\n <li>Create a <strong>Stripe</strong> account and copy your <strong>Secret key</strong>.</li>\n <li>In Supabase → Edge Functions → Secrets, add <code>STRIPE_SECRET_KEY</code> and <code>STRIPE_WEBHOOK_SECRET</code>.</li>\n <li>Deploy the <code>relay-create-payment</code> and <code>relay-stripe-webhook</code> functions.</li>\n <li>In Stripe → Developers → Webhooks, add an endpoint pointing at <code>relay-stripe-webhook</code> and subscribe it to <code>checkout.session.completed</code>.</li>\n </ol>\n </div>\n\n <div class=\"form-group\" style=\"display:flex;align-items:center;gap:10px;\">\n <label class=\"switch\" style=\"margin:0;\">\n <input type=\"checkbox\" id=\"pay-connected\" ${pay.connected ? 'checked' : ''} />\n </label>\n <div>\n <div style=\"font-weight:600;font-size:13px;\">Payments configured &amp; live</div>\n <div style=\"font-size:11px;color:var(--text-tertiary);\">Tick once the setup above is done. Controls whether the Pay Link button appears on invoices.</div>\n </div>\n </div>\n\n <div class=\"form-group\" style=\"max-width:220px;\">\n <label class=\"form-label\">Currency</label>\n <select class=\"form-input\" id=\"pay-currency\">\n ${currencies.map(c => `<option value=\"${c}\" ${c === cur ? 'selected' : ''}>${c}</option>`).join('')}\n </select>\n </div>\n\n <div class=\"form-group\" style=\"display:flex;align-items:center;gap:10px;\">\n <input type=\"checkbox\" id=\"pay-enable-invoice\" style=\"width:16px;height:16px;\" ${enabledFor.invoice !== false ? 'checked' : ''} />\n <label for=\"pay-enable-invoice\" style=\"margin:0;font-size:13px;\">Show a \"Pay Link\" action on sent invoices</label>\n </div>\n\n <div style=\"margin-top:16px;\">\n <button class=\"btn btn-primary\" id=\"pay-save\"><span class=\"material-icons-outlined\">save</span> Save Payment Settings</button>\n </div>\n </div>\n </div>`;\n\n tc.querySelector('#pay-save')?.addEventListener('click', async () => {\n try {\n const s = store.getSettings() || {};\n s.payments = {\n ...(s.payments || {}),\n connected: tc.querySelector('#pay-connected').checked,\n currency: tc.querySelector('#pay-currency').value,\n enabledFor: {\n ...((s.payments || {}).enabledFor || {}),\n invoice: tc.querySelector('#pay-enable-invoice').checked,\n },\n };\n await store.saveSettings(s);\n showToast('Payment settings saved', 'success');\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n } catch (err) {\n console.error('Error saving payment settings:', err);\n showToast('Could not save payment settings', 'error');\n }\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-00023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-00129',\n originalQuoteNumber: 'Q-00412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#FF5C00', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' },\n ballet: { name: 'Ballet Pointe', preset: 'ballet', accentColor: '#e58799', headerBg: '#5c2c36', accentTint: '#fff5f6', fontFamily: 'serif' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-company-name\" style=\"width:16px; height:16px\" ${dt.hideCompanyName ? 'checked' : ''} />\n <label for=\"theme-hide-company-name\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide company name text (use logo only)</label>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Variation</label>\n <select class=\"form-select\" id=\"theme-logo-source\" style=\"width:100%\">\n <option value=\"large\" ${dt.logoSource === 'large' ? 'selected' : ''}>Standard Logo (Large)</option>\n <option value=\"small\" ${dt.logoSource === 'small' ? 'selected' : ''}>Small / Shrunk Logo (Small)</option>\n </select>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:8px 0\"></div>\n <div class=\"form-group ${isLocalMode ? 'disabled-local' : ''}\" ${isLocalMode ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''} style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-brand-logo\" style=\"width:16px; height:16px\" ${dt.hideBrandLogo ? 'checked' : ''} ${isLocalMode ? 'disabled' : ''} />\n <label for=\"theme-hide-brand-logo\" class=\"form-label\" style=\"margin:0; cursor:${isLocalMode ? 'not-allowed' : 'pointer'}\">Remove Relay-dispatch brand logo from document footers</label>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n <div class=\"form-row\" style=\"display:flex; gap:12px\">\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Invoice Prefix</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoicePrefix\" value=\"${escapeHTML(dt.invoicePrefix || 'INV-')}\" placeholder=\"INV-\" />\n </div>\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Invoice Starting Number</label>\n <input type=\"number\" class=\"form-input text-copy-input\" data-prop=\"invoiceStartingNumber\" value=\"${dt.invoiceStartingNumber !== undefined ? dt.invoiceStartingNumber : 1}\" min=\"1\" placeholder=\"1\" />\n </div>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n <div class=\"form-row\" style=\"display:flex; gap:12px\">\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Quote Prefix</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quotePrefix\" value=\"${escapeHTML(dt.quotePrefix || 'Q-')}\" placeholder=\"Q-\" />\n </div>\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Quote Starting Number</label>\n <input type=\"number\" class=\"form-input text-copy-input\" data-prop=\"quoteStartingNumber\" value=\"${dt.quoteStartingNumber !== undefined ? dt.quoteStartingNumber : 1}\" min=\"1\" placeholder=\"1\" />\n </div>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Hide Company Name toggle\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) {\n hideCompanyNameChk.addEventListener('change', () => {\n dt.hideCompanyName = hideCompanyNameChk.checked;\n updatePreview();\n });\n }\n\n // Logo Variation toggle\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) {\n logoSourceSelect.addEventListener('change', (e) => {\n dt.logoSource = e.target.value;\n updatePreview();\n });\n }\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Hide Brand Logo toggle\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) {\n hideBrandLogoChk.addEventListener('change', () => {\n dt.hideBrandLogo = hideBrandLogoChk.checked;\n updatePreview();\n });\n }\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-theme-save');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n await store.saveSettings(freshSettings);\n showToast('Document customization settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving theme settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n hideCompanyName: false,\n hideBrandLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) hideCompanyNameChk.checked = !!dt.hideCompanyName;\n\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) logoSourceSelect.value = dt.logoSource || 'large';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePrefixInp = tc.querySelector('[data-prop=\"invoicePrefix\"]');\n if (invoicePrefixInp) invoicePrefixInp.value = dt.invoicePrefix || '';\n\n const invoiceStartingNumberInp = tc.querySelector('[data-prop=\"invoiceStartingNumber\"]');\n if (invoiceStartingNumberInp) invoiceStartingNumberInp.value = dt.invoiceStartingNumber !== undefined ? dt.invoiceStartingNumber : 1;\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const quotePrefixInp = tc.querySelector('[data-prop=\"quotePrefix\"]');\n if (quotePrefixInp) quotePrefixInp.value = dt.quotePrefix || '';\n\n const quoteStartingNumberInp = tc.querySelector('[data-prop=\"quoteStartingNumber\"]');\n if (quoteStartingNumberInp) quoteStartingNumberInp.value = dt.quoteStartingNumber !== undefined ? dt.quoteStartingNumber : 1;\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) hideBrandLogoChk.checked = !!dt.hideBrandLogo;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n const logoUrl = dt.logoSource === 'small' ? (settings.logoSmall || settings.logo) : (settings.logo || settings.logoSmall);\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.src = logoUrl || '';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // Company Name visibility\n const companyNameEl = frameDoc.querySelector('.pdf-company-name');\n if (companyNameEl) {\n companyNameEl.style.display = dt.hideCompanyName ? 'none' : 'block';\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n\n const footerBrandingEl = frameDoc.querySelector('.pdf-footer-branding');\n if (footerBrandingEl) {\n const hideBrandLogo = isLocalMode ? false : !!dt.hideBrandLogo;\n footerBrandingEl.style.display = hideBrandLogo ? 'none' : 'flex';\n }\n }\n\n renderMarkup();\n }\n\n function renderFolderSyncTab(tc) {\n const isSupported = typeof window !== 'undefined' && (\n (window.indexedDB && window.showDirectoryPicker) ||\n (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem)\n );\n\n const isCapacitor = typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem;\n\n const activeAccountId = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n const isLocalCompany = !!activeAccountId;\n\n function render() {\n console.log('[DEBUG Settings] render: isLocalCompany =', isLocalCompany, 'store.dirHandle =', store.dirHandle, 'permissionGranted =', store.folderSyncPermissionGranted);\n const isEnabled = isLocalCompany ? true : store.folderSyncEnabled;\n const isPermissionGranted = store.folderSyncPermissionGranted;\n const hasHandle = !!store.dirHandle;\n\n let statusHtml = '';\n if (!isSupported) {\n statusHtml = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:16px; border-radius:4px; color:var(--color-danger); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:8px; font-weight:600; margin-bottom:4px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Browser Directory Access Unsupported</span>\n </div>\n <p style=\"font-size:var(--font-size-sm); margin:0; line-height:1.4;\">\n Your current browser does not support local folder access. To enable direct folder synchronization, please run this app in a Chromium-based browser (Chrome, Edge, Opera, or as a compiled native app). High-capacity IndexedDB storage remains active.\n </p>\n </div>\n `;\n } else if (!isEnabled) {\n statusHtml = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:16px; border-radius:6px; color:var(--text-secondary); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px; color:var(--text-tertiary);\">folder_off</span>\n <div>\n <div style=\"font-weight:600; color:var(--text-primary); margin-bottom:2px;\">Folder Synchronization Inactive</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4;\">All data is currently stored in your browser's private IndexedDB database sandbox.</p>\n </div>\n </div>\n `;\n } else if (isCapacitor) {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">cloud_done</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Capacitor Direct Folder Sync Active</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Data is synchronizing directly to the application's native <strong>Documents/RelayDispatchData</strong> directory on your iPad/device.\n </p>\n </div>\n </div>\n `;\n } else if (!hasHandle) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">warning</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">No Directory Selected</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">Please select a directory folder on your computer to begin syncing.</p>\n </div>\n </div>\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:12px; margin-bottom:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">lock</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Access Permission Suspended</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n The browser requires re-authorization to read/write files in <strong>${escapeHTML(store.dirHandle.name)}</strong>.\n </p>\n </div>\n </div>\n <button class=\"btn btn-warning\" id=\"btn-reauthorize-dir\" style=\"margin-left:44px;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize Access\n </button>\n </div>\n `;\n } else {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">check_circle</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Folder Sync Active & Synchronized</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Active Folder: <strong>${escapeHTML(store.dirHandle.name)}</strong>. All data edits are writing dynamically.\n </p>\n </div>\n </div>\n `;\n }\n\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <!-- Folder Configuration -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Direct Directory Sync</h4></div>\n <div class=\"card-body\">\n ${statusHtml}\n\n <div class=\"form-group\" style=\"margin-bottom:var(--space-lg);\">\n <label class=\"form-label\" style=\"font-weight:600; margin-bottom:8px;\">Synchronization Toggle</label>\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\">\n <input type=\"checkbox\" id=\"toggle-folder-sync\" ${isEnabled ? 'checked' : ''} ${(!isSupported || isLocalCompany) ? 'disabled' : ''} style=\"width:20px; height:20px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\" />\n <div>\n <span style=\"font-weight:500;\">Enable Direct Local Folder Storage</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">\n ${isLocalCompany ? 'Mandatory for offline/local company profile storage.' : 'Saves all data records and attachments straight to your machine.'}\n </div>\n </div>\n </label>\n </div>\n\n ${isEnabled && isSupported && !isCapacitor ? `\n <div style=\"display:flex; gap:12px; flex-wrap:wrap; border-top:1px solid var(--border-color); padding-top:var(--space-lg);\">\n <button class=\"btn btn-secondary\" id=\"btn-pick-dir\">\n <span class=\"material-icons-outlined\">folder</span> Choose Sync Folder...\n </button>\n ${hasHandle && isPermissionGranted ? `\n <button class=\"btn btn-secondary\" id=\"btn-force-sync\" data-tooltip=\"Overwrite folder JSONs with current memory cache\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">sync</span> Sync Now\n </button>\n ${!isLocalCompany ? `\n <button class=\"btn btn-danger\" id=\"btn-disconnect-dir\">\n <span class=\"material-icons-outlined\">link_off</span> Disconnect Folder\n </button>\n ` : ''}\n ` : ''}\n </div>\n ` : ''}\n </div>\n </div>\n\n <!-- Instructions Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>How Self-Hosting Works</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p>\n By linking a local folder, you establish a <strong>serverless self-hosted data hub</strong>.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">storage</span>\n <span><strong>Readable Data:</strong> Your jobs, quotes, and company configurations are saved in <code>data/*.json</code> files.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">photo_library</span>\n <span><strong>Physical Attachments:</strong> Uploaded photos and PDF catalogs are saved as standard image/PDF files under the <code>documents/</code> subfolder.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">sync_alt</span>\n <span><strong>Cloud Backups:</strong> Select a folder synced to <strong>OneDrive, Google Drive, or Dropbox</strong> to automatically back up and sync your data to the cloud.</span>\n </div>\n </div>\n </div>\n </div>\n `;\n\n // Event Listeners\n const toggle = tc.querySelector('#toggle-folder-sync');\n toggle?.addEventListener('change', async (e) => {\n if (isLocalCompany) {\n e.preventDefault();\n toggle.checked = true;\n return;\n }\n\n if (isCapacitor) {\n await store.setLocalDirectory(e.target.checked ? {} : null);\n showToast(e.target.checked ? 'Capacitor folder sync activated' : 'Folder sync deactivated', 'success');\n render();\n return;\n }\n\n if (e.target.checked) {\n // Trigger directory picker\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory configured successfully', 'success');\n } catch (err) {\n console.error('Directory selection cancelled or failed:', err);\n toggle.checked = false;\n showToast('Folder selection cancelled', 'info');\n }\n } else {\n await store.setLocalDirectory(null);\n showToast('Direct directory synchronization disabled', 'info');\n }\n render();\n });\n\n tc.querySelector('#btn-pick-dir')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory updated successfully', 'success');\n render();\n } catch (err) {\n console.error(err);\n }\n });\n\n tc.querySelector('#btn-reauthorize-dir')?.addEventListener('click', async () => {\n const granted = await store.verifyDirPermission(true);\n if (granted) {\n showToast('Folder access re-authorized successfully', 'success');\n } else {\n showToast('Failed to acquire write permissions', 'error');\n }\n render();\n });\n\n tc.querySelector('#btn-disconnect-dir')?.addEventListener('click', async () => {\n await store.setLocalDirectory(null);\n showToast('Local folder disconnected', 'info');\n render();\n });\n\n tc.querySelector('#btn-force-sync')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n btn.disabled = true;\n showToast('Writing cache data to local directory...', 'info');\n try {\n const collections = Object.keys(store.cache);\n await Promise.all(collections.map(col => store.writeCollectionToFolder(col, store.cache[col])));\n showToast('Folder sync completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Folder sync failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n }\n });\n }\n\n render();\n }\n\n function renderApiKeysTab(tc) {\n const s = store.getSettings();\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const ai = s.ai || {\n enabled: false,\n apiKey: '',\n visionApiKey: '',\n useSameKey: true,\n endpoint: 'https://api.deepseek.com/chat/completions',\n model: 'deepseek-chat',\n visionEndpoint: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',\n visionModel: 'gemini-2.0-flash',\n systemPrompt: 'You are Deputy, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n };\n if (ai.useSameKey === undefined) ai.useSameKey = true;\n const maps = s.maps || { apiKey: '' };\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null') || { id: 'default' };\n const userId = currentUser.id || 'default';\n const factsheetKey = `relay_factsheet_${userId}`;\n const enabledKey = `relay_factsheet_enabled_${userId}`;\n const factsheetVal = localStorage.getItem(factsheetKey) || '';\n const memoryEnabled = localStorage.getItem(enabledKey) !== 'false';\n\n function render() {\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg);\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>AI Assistant Configuration</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n \n <div class=\"form-group\">\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:pointer;\">\n <input type=\"checkbox\" id=\"ai-enabled\" ${ai.enabled ? 'checked' : ''} style=\"width:20px; height:20px; cursor:pointer;\" />\n <div>\n <span style=\"font-weight:600;\">Enable AI Assistant</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">Replaces the basic rule-based Relay co-pilot with a smart conversational LLM.</div>\n </div>\n </label>\n </div>\n\n <div id=\"ai-fields\" style=\"display: ${ai.enabled ? 'flex' : 'none'}; flex-direction:column; gap:16px; border-top:1px solid var(--border-color); padding-top:16px;\">\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Text API Key</label>\n <input type=\"password\" class=\"form-input\" id=\"ai-apikey\" value=\"${ai.apiKey || ''}\" placeholder=\"${isLocalMode ? 'sk-...' : 'Optional — Defaults to secure cloud API key'}\" />\n </div>\n <div class=\"form-group\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <label class=\"form-label\" style=\"font-weight:600; margin-bottom:0;\">Vision API Key</label>\n <label style=\"display:flex; align-items:center; gap:6px; font-size:12px; cursor:pointer;\">\n <input type=\"checkbox\" id=\"ai-use-same-key\" ${ai.useSameKey ? 'checked' : ''} style=\"width:14px; height:14px;\" /> Use Text API Key\n </label>\n </div>\n <input type=\"password\" class=\"form-input\" id=\"ai-vision-apikey\" value=\"${ai.useSameKey ? (ai.apiKey || '') : (ai.visionApiKey || '')}\" placeholder=\"API Key for Vision Model\" style=\"margin-top:6px; ${ai.useSameKey ? 'opacity:0.5; pointer-events:none;' : ''}\" />\n </div>\n </div>\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:-8px;\">${isLocalMode ? 'Your API keys are stored locally in your browser/sync directory.' : 'Keys are optional. If blank, cloud operations will route securely through Edge Functions.'}</div>\n\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Text API Endpoint</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-endpoint\" value=\"${ai.endpoint || 'https://api.deepseek.com/chat/completions'}\" placeholder=\"https://api.deepseek.com/chat/completions\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Text Model Name</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-model\" value=\"${ai.model || 'deepseek-chat'}\" placeholder=\"deepseek-chat\" />\n </div>\n </div>\n\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Vision API Endpoint (Multimodal)</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-vision-endpoint\" value=\"${ai.visionEndpoint || 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions'}\" placeholder=\"https://api.openai.com/v1/chat/completions\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Vision Model Name</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-vision-model\" value=\"${ai.visionModel || 'gemini-2.0-flash'}\" placeholder=\"gpt-4o-mini\" />\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Base System Prompt</label>\n <textarea class=\"form-input\" id=\"ai-systemprompt\" rows=\"4\" style=\"font-family:inherit; resize:vertical;\">${ai.systemPrompt || ''}</textarea>\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.</div>\n </div>\n\n <div style=\"display:flex; gap:12px; align-items:center; margin-top:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-test-ai\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">bolt</span> Test Connection\n </button>\n <span id=\"test-status\" style=\"font-size:13px; font-weight:500;\"></span>\n </div>\n <div id=\"test-error-details\" style=\"display:none; margin-top:8px; font-size:12px; color:var(--color-danger); background:var(--color-danger-bg); border-left:3px solid var(--color-danger); padding:8px; border-radius:4px; line-height:1.4;\"></div>\n </div>\n\n <div style=\"border-top:1px solid var(--border-color); padding-top:16px; display:flex; justify-content:flex-end;\">\n <button class=\"btn btn-primary\" id=\"btn-save-ai\">Save AI Config</button>\n </div>\n\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Google Maps Configuration</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Maps API Key</label>\n <input type=\"password\" class=\"form-input\" id=\"maps-apikey\" value=\"${maps.apiKey || ''}\" placeholder=\"AIzaSy...\" />\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">Used for address autocomplete, distance calculations, and rendering maps. Get this from the Google Cloud Console.</div>\n </div>\n <div style=\"border-top:1px solid var(--border-color); padding-top:16px; display:flex; justify-content:flex-end;\">\n <button class=\"btn btn-primary\" id=\"btn-save-maps\">Save Maps Config</button>\n </div>\n </div>\n </div>\n\n </div>\n\n <!-- Cheapest Smart AI Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>Cheapest Smart AI Guide</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p style=\"margin:0;\">\n <strong>DeepSeek</strong> is one of the most cost-efficient and high-performance language models currently available, making it perfect for lightweight co-pilot tasks.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">key</span>\n <span><strong>API Key:</strong> Get your key from <a href=\"https://platform.deepseek.com/\" target=\"_blank\" style=\"color:var(--color-primary); text-decoration:underline;\">platform.deepseek.com</a>. New accounts typically receive free trial credits.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">security</span>\n <span><strong>CORS Restrictions:</strong> Due to browser security, direct API calls to DeepSeek may be blocked. To bypass this:\n <ul style=\"margin:4px 0 0 16px; padding:0; list-style:disc; font-size:12px;\">\n <li>Route via a CORS proxy (e.g. <code>https://cors-anywhere.herokuapp.com/</code> prefix).</li>\n <li>Connect via an aggregator like <strong>OpenRouter</strong> (API Endpoint: <code>https://openrouter.ai/api/v1/chat/completions</code>).</li>\n <li>Use a local LLM runner like <strong>Ollama</strong> (API Endpoint: <code>http://localhost:11434/v1/chat/completions</code>).</li>\n </ul>\n </span>\n </div>\n </div>\n </div>\n\n </div>\n `;\n\n // Toggle display of fields based on enabled checkbox\n const enabledCheckbox = tc.querySelector('#ai-enabled');\n const fieldsContainer = tc.querySelector('#ai-fields');\n enabledCheckbox.addEventListener('change', (e) => {\n fieldsContainer.style.display = e.target.checked ? 'flex' : 'none';\n });\n\n const useSameKeyCheckbox = tc.querySelector('#ai-use-same-key');\n const visionApiKeyInput = tc.querySelector('#ai-vision-apikey');\n const textApiKeyInput = tc.querySelector('#ai-apikey');\n if (useSameKeyCheckbox && visionApiKeyInput) {\n useSameKeyCheckbox.addEventListener('change', (e) => {\n if (e.target.checked) {\n visionApiKeyInput.style.opacity = '0.5';\n visionApiKeyInput.style.pointerEvents = 'none';\n if (textApiKeyInput) visionApiKeyInput.value = textApiKeyInput.value;\n } else {\n visionApiKeyInput.style.opacity = '1';\n visionApiKeyInput.style.pointerEvents = 'auto';\n visionApiKeyInput.focus();\n }\n });\n \n if (textApiKeyInput) {\n textApiKeyInput.addEventListener('input', (e) => {\n if (useSameKeyCheckbox.checked) {\n visionApiKeyInput.value = e.target.value;\n }\n });\n }\n }\n\n\n\n // Save handler\n tc.querySelector('#btn-save-ai').addEventListener('click', () => {\n const enabled = enabledCheckbox.checked;\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const useSameKey = tc.querySelector('#ai-use-same-key') ? tc.querySelector('#ai-use-same-key').checked : true;\n const visionApiKey = useSameKey ? apiKey : tc.querySelector('#ai-vision-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const visionEndpoint = tc.querySelector('#ai-vision-endpoint').value.trim();\n const visionModel = tc.querySelector('#ai-vision-model').value.trim();\n const systemPrompt = tc.querySelector('#ai-systemprompt').value.trim();\n\n if (isLocalMode && enabled && !apiKey) {\n showToast('Text API Key is required when enabling the AI assistant.', 'error');\n return;\n }\n\n const settings = store.getSettings();\n settings.ai = {\n enabled,\n apiKey,\n visionApiKey,\n useSameKey,\n endpoint,\n model,\n visionEndpoint,\n visionModel,\n systemPrompt\n };\n\n store.saveSettings(settings);\n showToast('AI settings saved successfully', 'success');\n });\n\n tc.querySelector('#btn-save-maps').addEventListener('click', () => {\n const apiKey = tc.querySelector('#maps-apikey').value.trim();\n const settings = store.getSettings();\n settings.maps = { apiKey };\n store.saveSettings(settings);\n showToast('Maps settings saved successfully', 'success');\n });\n\n // Test handler\n tc.querySelector('#btn-test-ai').addEventListener('click', async () => {\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const statusEl = tc.querySelector('#test-status');\n const errEl = tc.querySelector('#test-error-details');\n\n const inElectron = !!(window.electronAPI && window.electronAPI.callDeepSeek);\n const hasEnvKey = !!(import.meta.env.VITE_DEEPSEEK_API_KEY);\n\n if (!apiKey && !inElectron && !hasEnvKey) {\n showToast('Enter an API key to test.', 'error');\n return;\n }\n\n statusEl.style.color = 'var(--text-secondary)';\n statusEl.textContent = 'Testing connection...';\n errEl.style.display = 'none';\n\n const startTime = Date.now();\n try {\n if (inElectron) {\n const data = await window.electronAPI.callDeepSeek({\n messages: [{ role: 'user', content: 'respond only with the word OK' }],\n endpoint,\n model,\n apiKey // Pass typed key if any\n });\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success (via Electron IPC)! Latency: ${latency}ms (Reply: \"${reply}\")`;\n } else {\n const res = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY}`\n },\n body: JSON.stringify({\n model: model || 'deepseek-chat',\n messages: [\n { role: 'user', content: 'respond only with the word OK' }\n ],\n max_tokens: 5\n })\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`HTTP ${res.status}: ${body}`);\n }\n\n const data = await res.json();\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success! Latency: ${latency}ms (Reply: \"${reply}\")`;\n }\n } catch (err) {\n console.error('AI test connection failed:', err);\n statusEl.style.color = 'var(--color-danger)';\n statusEl.textContent = 'Test failed';\n errEl.style.display = 'block';\n\n let errMsg = err.message;\n if (err.name === 'TypeError' && err.message.includes('Failed to fetch')) {\n errMsg = 'CORS Blocked or Offline. The browser blocked this cross-origin request. Please use a CORS proxy, an API gateway like OpenRouter, or check your internet connection.';\n }\n errEl.textContent = errMsg;\n }\n });\n }\n\n render();\n }\n\n function renderCostCentersTab(tc) {\n const list = store.getAll('costCenters') || [];\n\n const renderTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:900px\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Cost Centers</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-cost-center\" style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span> Add Cost Center\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"padding-left:16px; width:120px\">Code</th>\n <th>Name</th>\n <th>Xero Mapping</th>\n <th style=\"width:120px\">Status</th>\n <th style=\"text-align:right; padding-right:16px; width:180px\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${list.length === 0 ? `\n <tr>\n <td colspan=\"5\" style=\"text-align:center; padding:32px 16px; color:var(--text-secondary)\">\n No Cost Centers configured. Click \"Add Cost Center\" to create one.\n </td>\n </tr>\n ` : list.map(cc => `\n <tr>\n <td style=\"padding-left:16px; font-weight:600\">${escapeHTML(cc.code)}</td>\n <td>${escapeHTML(cc.name)}</td>\n <td>\n ${cc.xeroSalesAccountCode || cc.xeroTrackingOptionName ? `\n <div style=\"font-size:11px; line-height:1.4\">\n ${cc.xeroSalesAccountCode ? `<div><span class=\"text-tertiary\">Sales Code:</span> <strong>${escapeHTML(cc.xeroSalesAccountCode)}</strong></div>` : ''}\n ${cc.xeroExpenseAccountCode ? `<div><span class=\"text-tertiary\">Expense Code:</span> <strong>${escapeHTML(cc.xeroExpenseAccountCode)}</strong></div>` : ''}\n ${cc.xeroTrackingOptionName ? `<div><span class=\"text-tertiary\">Tracking:</span> <strong>${escapeHTML(cc.xeroTrackingCategoryName || 'Department')}:${escapeHTML(cc.xeroTrackingOptionName)}</strong></div>` : ''}\n </div>\n ` : '<span class=\"text-tertiary\" style=\"font-size:11px\">— Unmapped —</span>'}\n </td>\n <td>\n <span class=\"badge ${cc.active ? 'badge-success' : 'badge-neutral'}\">\n ${cc.active ? 'Active' : 'Inactive'}\n </span>\n </td>\n <td style=\"text-align:right; padding-right:16px\">\n <button class=\"btn btn-secondary btn-sm btn-edit-cc\" data-id=\"${cc.id}\" style=\"margin-right:6px\">Edit</button>\n <button class=\"btn btn-outline btn-sm btn-toggle-cc\" data-id=\"${cc.id}\">\n ${cc.active ? 'Disable' : 'Enable'}\n </button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-cost-center')?.addEventListener('click', () => openCostCenterModal());\n tc.querySelectorAll('.btn-edit-cc').forEach(btn => {\n btn.addEventListener('click', (e) => openCostCenterModal(e.target.dataset.id));\n });\n tc.querySelectorAll('.btn-toggle-cc').forEach(btn => {\n btn.addEventListener('click', async (e) => {\n const id = e.target.dataset.id;\n const cc = store.getById('costCenters', id);\n if (cc) {\n const newActive = !cc.active;\n await store.update('costCenters', id, { active: newActive });\n showToast(`Cost center ${cc.name} has been ${newActive ? 'enabled' : 'disabled'}.`, 'success');\n renderCostCentersTab(tc);\n }\n });\n });\n };\n\n renderTab();\n }\n\n function openCostCenterModal(editId = null) {\n let cc = editId ? store.getById('costCenters', editId) : { \n code: '', \n name: '', \n active: true,\n xeroSalesAccountCode: '',\n xeroExpenseAccountCode: '',\n xeroTrackingCategoryName: 'Department',\n xeroTrackingOptionName: ''\n };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\" style=\"margin-bottom:16px\">\n <label class=\"form-label\" style=\"display:block; margin-bottom:6px\">Cost Center Code</label>\n <input class=\"form-input\" id=\"cc-code\" value=\"${escapeHTML(cc.code)}\" placeholder=\"e.g. ELEC\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\" style=\"margin-bottom:16px\">\n <label class=\"form-label\" style=\"display:block; margin-bottom:6px\">Cost Center Name</label>\n <input class=\"form-input\" id=\"cc-name\" value=\"${escapeHTML(cc.name)}\" placeholder=\"e.g. Electrical Services\" style=\"width:100%\" />\n </div>\n \n <fieldset style=\"border: 1px solid var(--border-color); border-radius: 6px; padding: 12px; margin-bottom: 16px; background:var(--card-bg)\">\n <legend style=\"padding: 0 6px; font-size: 11px; font-weight: 600; color: var(--color-primary); display: flex; align-items: center; gap: 4px; margin: 0\">\n <span class=\"material-icons-outlined\" style=\"font-size:15px\">sync</span> Xero Integration (Optional)\n </legend>\n <div class=\"form-row\" style=\"margin-bottom:12px; display:grid; grid-template-columns:1fr 1fr; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Sales Account Code</label>\n <input class=\"form-input\" id=\"cc-xero-sales\" value=\"${escapeHTML(cc.xeroSalesAccountCode || '')}\" placeholder=\"e.g. 200\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Expense Account Code</label>\n <input class=\"form-input\" id=\"cc-xero-expense\" value=\"${escapeHTML(cc.xeroExpenseAccountCode || '')}\" placeholder=\"e.g. 300\" style=\"width:100%\" />\n </div>\n </div>\n <div class=\"form-row\" style=\"display:grid; grid-template-columns:1fr 1fr; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Tracking Category</label>\n <input class=\"form-input\" id=\"cc-xero-category\" value=\"${escapeHTML(cc.xeroTrackingCategoryName || 'Department')}\" placeholder=\"e.g. Department\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Tracking Option</label>\n <input class=\"form-input\" id=\"cc-xero-option\" value=\"${escapeHTML(cc.xeroTrackingOptionName || '')}\" placeholder=\"e.g. Electrical\" style=\"width:100%\" />\n </div>\n </div>\n </fieldset>\n\n <div class=\"form-group\">\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer\">\n <input type=\"checkbox\" id=\"cc-active\" ${cc.active ? 'checked' : ''} style=\"width:16px; height:16px; margin:0\" />\n <span>Active</span>\n </label>\n </div>\n `;\n\n showModal({\n title: editId ? 'Edit Cost Center' : 'Add Cost Center',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-cc', onClick: async (c) => {\n const code = document.getElementById('cc-code').value.trim().toUpperCase();\n const name = document.getElementById('cc-name').value.trim();\n const active = document.getElementById('cc-active').checked;\n const xeroSalesAccountCode = document.getElementById('cc-xero-sales').value.trim();\n const xeroExpenseAccountCode = document.getElementById('cc-xero-expense').value.trim();\n const xeroTrackingCategoryName = document.getElementById('cc-xero-category').value.trim();\n const xeroTrackingOptionName = document.getElementById('cc-xero-option').value.trim();\n\n if (!code) { showToast('Code required', 'error'); return; }\n if (!name) { showToast('Name required', 'error'); return; }\n\n try {\n const updates = { \n code, \n name, \n active,\n xeroSalesAccountCode: xeroSalesAccountCode || null,\n xeroExpenseAccountCode: xeroExpenseAccountCode || null,\n xeroTrackingCategoryName: xeroTrackingCategoryName || null,\n xeroTrackingOptionName: xeroTrackingOptionName || null\n };\n if (editId) {\n await store.update('costCenters', editId, updates);\n showToast('Cost Center updated successfully', 'success');\n } else {\n await store.create('costCenters', updates);\n showToast('Cost Center created successfully', 'success');\n }\n c();\n const tc = document.querySelector('#settings-content');\n renderCostCentersTab(tc);\n } catch (err) {\n console.error('Error saving cost center:', err);\n showToast('Failed to save cost center: ' + err.message, 'error');\n }\n }}\n ]\n });\n }\n\n function renderSuppliersSettings(tc) {\n const settings = store.getSettings();\n const categories = settings.supplierCategories || ['Electrical', 'Plumbing', 'HVAC', 'Fire Safety', 'Security', 'General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Supplier Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Define classifications/categories for your suppliers (e.g. Electrical, Plumbing, HVAC). These categories are used to group suppliers in the Suppliers directory.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"supplier-categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${escapeHTML(c)}\n <span class=\"material-icons-outlined btn-remove-supplier-cat\" data-name=\"${escapeHTML(c)}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-supplier-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-suppliers\" data-tooltip=\"Save supplier categories settings\" data-tooltip-pos=\"top\">Save Supplier Settings</button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-supplier-category').addEventListener('click', () => {\n const name = prompt('Enter supplier category name:');\n if (name) {\n const trimmed = name.trim();\n if (!trimmed) return;\n const exists = Array.from(tc.querySelectorAll('.btn-remove-supplier-cat')).some(span => span.dataset.name.toLowerCase() === trimmed.toLowerCase());\n if (exists) {\n showToast('Category already exists', 'error');\n return;\n }\n\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${escapeHTML(trimmed)}\n <span class=\"material-icons-outlined btn-remove-supplier-cat\" data-name=\"${escapeHTML(trimmed)}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#supplier-categories-container').insertBefore(btn, tc.querySelector('#btn-add-supplier-category'));\n btn.querySelector('.btn-remove-supplier-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-supplier-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-suppliers').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-suppliers');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const supplierCategories = Array.from(tc.querySelectorAll('.btn-remove-supplier-cat')).map(span => span.dataset.name);\n const updatedSettings = {\n ...settings,\n supplierCategories\n };\n await store.saveSettings(updatedSettings);\n showToast('Supplier settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving supplier settings:', err);\n showToast('Failed to save supplier settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n }\n"}, "6e9d5e921e208ca08131501560544ba77315171f": {"t": 1785754170.9847717, "n": 285192, "h": "ebadd18b53f9dcbf", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData, seedData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\nimport { FLAGS } from '../utils/flags.js';\nimport { applyTheme, THEMES } from '../utils/theme.js';\nimport { storageGet, storageSet } from '../utils/tauriStore.js';\n\n// Compress uploaded images using Canvas to avoid huge Base64 data payloads\nfunction compressImage(dataUrl, maxWidth, maxHeight) {\n return new Promise((resolve) => {\n if (!dataUrl) {\n resolve(dataUrl);\n return;\n }\n // SVG vectors are already lightweight; do not compress them\n if (dataUrl.startsWith('data:image/svg+xml')) {\n resolve(dataUrl);\n return;\n }\n const img = new Image();\n img.onload = () => {\n let width = img.width;\n let height = img.height;\n\n if (width > height) {\n if (width > maxWidth) {\n height = Math.round((height * maxWidth) / width);\n width = maxWidth;\n }\n } else {\n if (height > maxHeight) {\n width = Math.round((width * maxHeight) / height);\n height = maxHeight;\n }\n }\n\n const canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n\n const ctx = canvas.getContext('2d');\n ctx.drawImage(img, 0, 0, width, height);\n\n const isPng = dataUrl.includes('image/png');\n const format = isPng ? 'image/png' : 'image/jpeg';\n const quality = isPng ? undefined : 0.85;\n\n resolve(canvas.toDataURL(format, quality));\n };\n img.onerror = () => {\n resolve(dataUrl);\n };\n img.src = dataUrl;\n });\n}\n\n// Helper to hash password using SHA-256 Web Crypto API\nasync function hashPassword(password) {\n const msgBuffer = new TextEncoder().encode(password);\n const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);\n const hashArray = Array.from(new Uint8Array(hashBuffer));\n return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');\n}\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n const localDeploymentType = settings.localDeploymentType || 'single_user';\n\n const isUsersDisabled = isLocalMode && localDeploymentType === 'single_user';\n const isPortalDisabled = isLocalMode;\n const isFolderSyncDisabled = !isLocalMode;\n\n if (isUsersDisabled && activeTab === 'users') {\n activeTab = 'company';\n }\n if (isPortalDisabled && activeTab === 'portal') {\n activeTab = 'company';\n }\n if (isPortalDisabled && activeTab === 'portal_contractor') {\n activeTab = 'company';\n }\n if (isFolderSyncDisabled && activeTab === 'folder_sync') {\n activeTab = 'company';\n }\n \n const isAIAssistantDisabled = isLocalMode;\n if (isAIAssistantDisabled && activeTab === 'ai_assistant') {\n activeTab = 'company';\n }\n\n function getCategoryForTab(tab) {\n if (['company', 'portal', 'portal_contractor', 'folder_sync', 'ai_assistant', 'system'].includes(tab)) return 'general';\n if (['templates_forms', 'invoices_quotes', 'payments'].includes(tab)) return 'workflow';\n if (['users', 'suppliers'].includes(tab)) return 'people';\n if (['materials', 'cost_centers', 'tax'].includes(tab)) return 'resources';\n return 'general';\n }\n\n let activeCategory = getCategoryForTab(activeTab);\n\n const openMigrationModal = () => {\n const modalContent = document.createElement('div');\n modalContent.innerHTML = `\n <form id=\"convert-cloud-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Cloud Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a local/offline account. All data will be migrated to the secure cloud database.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"migrate-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"Company Name\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"migrate-admin-name\" required placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Phone Number</label>\n <input class=\"form-input\" id=\"migrate-admin-phone\" required placeholder=\"e.g. 0412 345 678\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Email Address (Username)</label>\n <input class=\"form-input\" type=\"email\" id=\"migrate-admin-email\" required placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Password</label>\n <input class=\"form-input\" type=\"password\" id=\"migrate-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"migration-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"migration-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-migrate-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-migrate-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" id=\"submit-icon\" style=\"font-size:18px;\">cloud_done</span>\n <span id=\"submit-text\">Register & Start Migration</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: 'Register & Migrate to Cloud',\n content: modalContent,\n size: 'modal-md'\n });\n\n modalContent.querySelector('#btn-migrate-cancel').addEventListener('click', close);\n\n const form = modalContent.querySelector('#convert-cloud-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n\n const errorEl = modalContent.querySelector('#migration-error');\n const errorTextEl = modalContent.querySelector('#migration-error-text');\n const submitBtn = modalContent.querySelector('#btn-migrate-submit');\n const cancelBtn = modalContent.querySelector('#btn-migrate-cancel');\n const submitText = modalContent.querySelector('#submit-text');\n const submitIcon = modalContent.querySelector('#submit-icon');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n submitText.textContent = 'Migrating to Cloud...';\n submitIcon.className = 'material-icons-outlined spinner';\n submitIcon.textContent = 'sync';\n submitIcon.style.animation = 'spin 1s linear infinite';\n\n const companyName = modalContent.querySelector('#migrate-company-name').value.trim();\n const adminName = modalContent.querySelector('#migrate-admin-name').value.trim();\n const adminPhone = modalContent.querySelector('#migrate-admin-phone').value.trim();\n const email = modalContent.querySelector('#migrate-admin-email').value.trim();\n const password = modalContent.querySelector('#migrate-admin-password').value;\n\n try {\n const settings = store.getSettings();\n settings.name = companyName;\n await store.saveSettings(settings);\n\n const { supabase } = await import('../utils/supabase.js');\n\n const { data: authData, error: authErr } = await supabase.auth.signUp({\n email,\n password,\n options: {\n data: {\n name: adminName,\n phone: adminPhone\n }\n }\n });\n if (authErr) throw authErr;\n\n if (!authData.user) {\n throw new Error('Verification required or signup was blocked. Check your email inbox.');\n }\n\n const { data: companyId, error: rpcError } = await supabase.rpc('create_company_and_admin', {\n user_id: authData.user.id,\n company_name: companyName,\n admin_name: adminName,\n admin_phone: adminPhone\n });\n if (rpcError) throw rpcError;\n\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n await store.migrateLocalToCloud(companyId, authData.user.id);\n\n if (activeAccountId) {\n const localAccountsKey = 'relay_accounts';\n let localAccounts = [];\n try {\n const stored = localStorage.getItem(localAccountsKey);\n if (stored) {\n localAccounts = JSON.parse(stored);\n }\n } catch (e) {\n console.error('Error reading local accounts:', e);\n }\n localAccounts = localAccounts.filter(a => a.id !== activeAccountId);\n localStorage.setItem(localAccountsKey, JSON.stringify(localAccounts));\n\n store.deleteLocalAccountData(activeAccountId);\n }\n\n const { data: profile, error: profileErr } = await supabase\n .from('profiles')\n .select('*')\n .eq('id', authData.user.id)\n .single();\n if (profileErr) throw profileErr;\n\n const user = {\n id: profile.id,\n companyId: profile.company_id,\n name: profile.name,\n role: profile.role,\n userTypeName: 'Admin',\n userTypeId: `${profile.company_id}_ut_admin`,\n color: profile.color || '#FF5C00',\n theme: 'light'\n };\n\n localStorage.setItem('currentUser', JSON.stringify(user));\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Migration completed successfully! Redirecting...', 'success');\n close();\n\n setTimeout(() => {\n window.location.hash = '#/';\n window.location.reload();\n }, 1500);\n\n } catch (err) {\n console.error('Migration failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during migration.';\n errorEl.style.display = 'flex';\n\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n submitText.textContent = 'Register & Start Migration';\n submitIcon.className = 'material-icons-outlined';\n submitIcon.textContent = 'cloud_done';\n submitIcon.style.animation = '';\n }\n });\n };\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n const categories = [\n {\n id: 'general',\n label: 'General',\n icon: 'settings',\n tabs: [\n { id: 'company', label: 'Company Profile' },\n { id: 'portal', label: 'Customer Portal', disabled: isPortalDisabled, tooltip: 'Requires Cloud Account' },\n { id: 'portal_contractor', label: 'Contractor Portal', disabled: isPortalDisabled, tooltip: 'Requires Cloud Account' },\n { id: 'folder_sync', label: 'Folder Sync', disabled: isFolderSyncDisabled, tooltip: 'Requires Local Folder Storage' },\n { id: 'api_keys', label: 'Local System Configuration' },\n { id: 'system', label: 'System Options' }\n ]\n },\n {\n id: 'workflow',\n label: 'Workflow',\n icon: 'account_tree',\n tabs: [\n { id: 'templates_forms', label: 'Templates & Forms' },\n { id: 'invoices_quotes', label: 'Quotes & Invoices' },\n // v1.3 #3 — dark until FLAGS.payments flips on for launch\n ...(FLAGS.payments ? [{ id: 'payments', label: 'Payments' }] : [])\n ]\n },\n {\n id: 'people',\n label: 'People',\n icon: 'groups',\n tabs: [\n { id: 'users', label: 'Users & Permissions', disabled: isUsersDisabled, tooltip: 'Requires Cloud Account or Local Server' },\n { id: 'suppliers', label: 'Suppliers Configuration' }\n ]\n },\n {\n id: 'resources',\n label: 'Resources',\n icon: 'widgets',\n tabs: [\n { id: 'materials', label: 'Materials & Kits' },\n { id: 'cost_centers', label: 'Cost Centers & Xero' },\n { id: 'tax', label: 'Tax & Labor Rates' }\n ]\n }\n ];\n\n function render() {\n const categoryTabsHtml = categories.map(cat => {\n const isActive = activeCategory === cat.id;\n return `\n <button class=\"settings-category-btn ${isActive ? 'active' : ''}\" data-category=\"${cat.id}\">\n <span class=\"material-icons-outlined\">${cat.icon}</span>\n <span>${cat.label}</span>\n </button>\n `;\n }).join('');\n\n const activeCatData = categories.find(cat => cat.id === activeCategory) || categories[0];\n const subTabsHtml = activeCatData.tabs.map(tab => {\n const isActive = activeTab === tab.id;\n const isDisabled = tab.disabled;\n const tooltipAttr = isDisabled ? `data-tooltip=\"${tab.tooltip}\" data-tooltip-pos=\"top\"` : '';\n return `\n <button class=\"settings-tab-btn ${isActive ? 'active' : ''} ${isDisabled ? 'disabled-local' : ''}\" data-tab=\"${tab.id}\" ${tooltipAttr}>\n ${tab.label}\n </button>\n `;\n }).join('');\n\n container.innerHTML = `\n <style>\n .settings-nav-categories {\n display: flex;\n gap: 8px;\n border-bottom: 2px solid var(--border-color);\n padding-bottom: 12px;\n margin-bottom: 20px;\n }\n .settings-category-btn {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 10px 20px;\n font-size: 15px;\n font-weight: 600;\n color: var(--text-secondary);\n background: transparent;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n .settings-category-btn .material-icons-outlined {\n font-size: 20px;\n transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n .settings-category-btn:hover {\n color: var(--text-primary);\n background: var(--bg-color);\n }\n .settings-category-btn:hover .material-icons-outlined {\n transform: scale(1.08);\n }\n .settings-category-btn.active {\n color: white;\n background: var(--color-primary);\n box-shadow: 0 4px 14px rgba(255, 92, 0, 0.25);\n transform: translateY(-1px);\n }\n \n .settings-nav-tabs {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-bottom: 24px;\n background: var(--bg-color);\n padding: 6px;\n border-radius: 10px;\n border: 1px solid var(--border-color);\n animation: subtabs-slide-in 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);\n }\n @keyframes subtabs-slide-in {\n from {\n opacity: 0;\n transform: translateX(-10px);\n }\n to {\n opacity: 1;\n transform: translateX(0);\n }\n }\n .settings-tab-btn {\n padding: 8px 16px;\n font-size: var(--font-size-sm);\n font-weight: 500;\n color: var(--text-secondary);\n background: transparent;\n border: none;\n border-radius: 20px;\n cursor: pointer;\n transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n .settings-tab-btn:hover:not(.disabled-local) {\n color: var(--text-primary);\n background: rgba(128, 128, 128, 0.08);\n }\n .settings-tab-btn.active {\n color: var(--color-primary);\n background: var(--content-bg);\n font-weight: 600;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);\n transform: scale(1.02);\n }\n \n #settings-content {\n animation: settings-fade-in 0.28s cubic-bezier(0.4, 0, 0.2, 1);\n }\n @keyframes settings-fade-in {\n from {\n opacity: 0;\n transform: translateY(6px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n }\n </style>\n\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"settings-nav-categories\">\n ${categoryTabsHtml}\n </div>\n\n <div class=\"settings-nav-tabs\">\n ${subTabsHtml}\n </div>\n\n <div id=\"settings-content\" style=\"padding-top:var(--space-sm)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.settings-category-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n const catId = btn.dataset.category;\n if (activeCategory === catId) return;\n\n activeCategory = catId;\n const catData = categories.find(c => c.id === catId);\n const firstActiveTab = catData.tabs.find(t => !t.disabled) || catData.tabs[0];\n activeTab = firstActiveTab.id;\n\n render();\n });\n });\n\n container.querySelectorAll('.settings-tab-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n if (btn.classList.contains('disabled-local')) return;\n activeTab = btn.dataset.tab;\n render();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'suppliers') {\n renderSuppliersSettings(tc);\n return;\n }\n\n if (activeTab === 'folder_sync') {\n renderFolderSyncTab(tc);\n return;\n }\n\n if (activeTab === 'api_keys') {\n renderApiKeysTab(tc);\n return;\n }\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'payments') {\n renderPaymentsTab(tc);\n return;\n }\n\n if (activeTab === 'cost_centers') {\n renderCostCentersTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n\n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Standard Logo (Large) using Canvas to ~600x300 max bounding box\n compressImage(re.target.result, 600, 300).then((compressed) => {\n pendingLogo = compressed;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Small/Shrunk Logo using Canvas to ~200x100 max bounding box\n compressImage(re.target.result, 200, 100).then((compressed) => {\n pendingLogoSmall = compressed;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n\n\n tc.querySelector('#btn-save-company').addEventListener('click', async () => {\n const saveBtn = tc.querySelector('#btn-save-company');\n const originalHtml = saveBtn.innerHTML;\n saveBtn.disabled = true;\n saveBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving Changes...';\n\n try {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n await store.saveSettings(settings);\n showToast('Company information saved successfully to database', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n render();\n } catch (err) {\n console.error('Error saving company profile settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n saveBtn.disabled = false;\n saveBtn.innerHTML = originalHtml;\n }\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n const pendingResets = store.getAll('passwordResetRequests') || [];\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div style=\"background:rgba(59, 130, 246, 0.1); border-left:4px solid #3b82f6; padding:12px 16px; margin-bottom:var(--space-md); border-radius:4px; font-size:13px; color:#f8fafc; display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:#3b82f6; font-size:18px;\">info</span>\n <span>Your company login code is <strong style=\"color:#60a5fa\">${companySlug}</strong>. Technicians log in using <strong style=\"color:#60a5fa\">username@${companySlug}</strong>.</span>\n </span>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Username</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n ${ut?.id !== 'ut_admin' ? `\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n ` : ''}\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\">\n <h4 style=\"margin:0\">Pending Password Reset Requests</h4>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Username/Email</th>\n <th>Requested On</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:24px\">No pending reset requests</td></tr>' : ''}\n ${pendingResets.filter(r => r.status === 'pending').map(r => {\n const tech = techs.find(t => t.id === r.technician_id) || {};\n const requestedAt = r.requested_at ? new Date(r.requested_at).toLocaleString() : 'Unknown';\n return `\n <tr>\n <td class=\"font-medium\">${tech.name || 'Unknown'}</td>\n <td class=\"text-secondary\">${r.employee_id || tech.username || ''}</td>\n <td class=\"text-tertiary\">${requestedAt}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-approve-reset\" data-id=\"${r.id}\" data-tech-id=\"${tech.id}\">Approve &amp; Reset</button>\n <button class=\"btn btn-sm btn-ghost text-danger btn-deny-reset\" data-id=\"${r.id}\">Deny</button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n ${isLocalMode ? '' : `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `}\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => {\n openUserModal();\n });\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-approve-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const techId = e.currentTarget.dataset.techId;\n const t = store.getById('technicians', techId);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <p>Approve password reset request for <strong>${t.name}</strong>? Please enter their new password below:</p>\n <div class=\"form-group\" style=\"margin-top:12px\">\n <label class=\"form-label\">New Password</label>\n <input type=\"password\" id=\"admin-reset-pwd-input\" class=\"form-input\" placeholder=\"Min. 6 characters\" minlength=\"6\" autofocus />\n </div>\n `;\n showModal({\n title: 'Approve Password Reset',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reset Password', className: 'btn-primary', onClick: c => {\n const pwdInput = document.getElementById('admin-reset-pwd-input');\n const newPwd = pwdInput ? pwdInput.value : '';\n if (!newPwd || newPwd.length < 6) {\n showToast('Password must be at least 6 characters.', 'error');\n return;\n }\n \n // Update technician password\n store.update('technicians', techId, { password: newPwd });\n \n // Approve the request\n store.update('passwordResetRequests', id, { status: 'approved', updated_at: new Date().toISOString() });\n \n showToast(`Password updated for ${t.name}`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-deny-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n store.update('passwordResetRequests', id, { status: 'denied', updated_at: new Date().toISOString() });\n showToast('Password reset request denied', 'info');\n renderContent();\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n let currentJobTypes = settings.jobTypes ? [...settings.jobTypes] : ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'];\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg)\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax Rates</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\" style=\"display:flex; flex-direction:column; gap:8px\">\n <label class=\"form-label\" style=\"display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:0\">\n <input type=\"checkbox\" id=\"tax-enabled\" style=\"width:16px; height:16px; margin:0\" ${settings.taxEnabled !== false ? 'checked' : ''} />\n Enable GST / Sales Tax\n </label>\n <div style=\"display:${settings.taxEnabled !== false ? 'flex' : 'none'}; align-items:center; gap:8px; margin-top:4px\" id=\"tax-rate-container\">\n <input class=\"form-input\" id=\"tax-rate\" type=\"number\" value=\"${settings.taxRate !== undefined ? settings.taxRate : 10}\" style=\"width:100px\" min=\"0\" max=\"100\" step=\"0.1\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" id=\"job-types-card\">\n <div class=\"card-header\"><h4>Job Types</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:12px\">Define the types of jobs your team performs (e.g. Electrical, Plumbing). These appear in job forms and rate mapping.</p>\n <div id=\"job-types-list\" style=\"display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px;\">\n ${currentJobTypes.map(type => `\n <span class=\"tag-pill tag-pill-active\" style=\"display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:100px; font-weight:500; font-size:13px; background:var(--color-primary-light); color:var(--color-primary-dark)\">\n <span>${escapeHTML(type)}</span>\n <span class=\"material-icons-outlined delete-job-type-btn\" data-type=\"${escapeHTML(type)}\" style=\"font-size:16px; cursor:pointer; opacity:0.7; transition:opacity 0.2s\">close</span>\n </span>\n `).join('')}\n </div>\n <div style=\"display:flex; gap:8px;\">\n <input class=\"form-input\" id=\"new-job-type-input\" type=\"text\" placeholder=\"e.g. Renovation\" style=\"flex:1\" />\n <button class=\"btn btn-secondary\" id=\"add-job-type-btn\" style=\"display:flex; align-items:center; gap:4px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${escapeHTML(rate.name)}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${escapeHTML(rate.description || '')}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => {\n const active = applicable.includes(day);\n return `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${active ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'};background:${active ? 'var(--color-primary-light)' : 'transparent'};color:${active ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `}).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // Toggle tax rate container visibility\n tc.querySelector('#tax-enabled')?.addEventListener('change', (e) => {\n const container = tc.querySelector('#tax-rate-container');\n if (container) {\n container.style.display = e.target.checked ? 'flex' : 'none';\n }\n });\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Job Types Management Event Listeners ----\n const addJobTypeBtn = tc.querySelector('#add-job-type-btn');\n const newJobTypeInput = tc.querySelector('#new-job-type-input');\n const jobTypesList = tc.querySelector('#job-types-list');\n\n function updateJobTypesUI() {\n jobTypesList.innerHTML = currentJobTypes.map(type => `\n <span class=\"tag-pill tag-pill-active\" style=\"display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:100px; font-weight:500; font-size:13px; background:var(--color-primary-light); color:var(--color-primary-dark)\">\n <span>${escapeHTML(type)}</span>\n <span class=\"material-icons-outlined delete-job-type-btn\" data-type=\"${escapeHTML(type)}\" style=\"font-size:16px; cursor:pointer; opacity:0.7; transition:opacity 0.2s\">close</span>\n </span>\n `).join('');\n }\n\n addJobTypeBtn?.addEventListener('click', () => {\n const val = newJobTypeInput.value.trim();\n if (!val) {\n showToast('Please enter a job type name', 'warning');\n return;\n }\n if (currentJobTypes.includes(val)) {\n showToast('This job type already exists', 'warning');\n return;\n }\n currentJobTypes.push(val);\n newJobTypeInput.value = '';\n updateJobTypesUI();\n });\n\n tc.querySelector('#job-types-card')?.addEventListener('click', (e) => {\n const btn = e.target.closest('.delete-job-type-btn');\n if (btn) {\n const typeToDelete = btn.dataset.type;\n currentJobTypes = currentJobTypes.filter(t => t !== typeToDelete);\n updateJobTypesUI();\n }\n });\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#save-tax-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const taxEnabled = tc.querySelector('#tax-enabled').checked;\n const taxRate = parseFloat(tc.querySelector('#tax-rate').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.taxEnabled = taxEnabled;\n settings.taxRate = taxRate;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n settings.jobTypes = currentJobTypes;\n\n await store.saveSettings(settings);\n showToast('Financial and Rate settings saved successfully', 'success');\n renderContent();\n } catch (err) {\n console.error('Error saving tax and rate settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-portal-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n await store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving customer portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'portal_contractor') {\n const s = store.getSettings();\n const portalEnabled = s.enableContractorPortal !== false;\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Contractor Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"contractor-portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('contractor-portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Contractor Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any subcontractor attempting to load their portal token will see an access deactivated notice.</span>\n </div>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-contractor-settings\" data-tooltip=\"Save subcontractor portal access rules\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Contractor Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-contractor-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-contractor-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableContractorPortal = tc.querySelector('#contractor-portal-enable').checked;\n\n await store.saveSettings(settings);\n showToast('Contractor portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving contractor portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'system') {\n\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n const currentTheme = (currentUser && currentUser.id) ? (currentUser.theme || localStorage.getItem(`simpro_theme_${currentUser.id}`) || 'light') : 'light';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Left Column -->\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg)\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n </p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n ${currentUser.role === 'admin' ? `\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed a complete demonstration dataset\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed Demonstration Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n <button class=\"btn btn-danger\" id=\"btn-delete-company\" data-tooltip=\"Permanently delete the entire company profile and all data\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; background:var(--color-danger); border-color:var(--color-danger); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span> Delete Company Profile\n </button>\n\n ${isLocalMode ? `\n <div style=\"margin-top:16px; padding-top:12px; border-top:1px dashed rgba(220, 38, 38, 0.2)\">\n <h6 style=\"color:var(--color-warning); margin-bottom:8px; font-weight:600; display:flex; align-items:center; gap:4px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">warning</span> Irreversible Profile Conversions\n </h6>\n <p style=\"font-size:11px; color:var(--text-secondary); margin-bottom:12px; line-height:1.3;\">\n Convert your deployment type. These transitions are permanent and one-way.\n </p>\n ${localDeploymentType === 'single_user' ? `\n <button class=\"btn\" id=\"btn-convert-multiuser\" style=\"width:100%; justify-content:center; margin-bottom:12px; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">lan</span> Convert to Multi-User Local Network Sync\n </button>\n ` : ''}\n <button class=\"btn\" id=\"btn-convert-cloud-action\" style=\"width:100%; justify-content:center; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">cloud_upload</span> Convert to Cloud Sync\n </button>\n </div>\n ` : ''}\n </div>\n ` : '<div style=\"font-size:12.5px; color:var(--text-tertiary)\">Administrative actions are restricted to company administrators.</div>'}\n </div>\n </div>\n </div>\n\n <!-- Local Data Backup -->\n ${!isLocalMode && currentUser.role === 'admin' ? `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary)\">backup</span>\n <h4 style=\"margin:0;\">Local Data Backup</h4>\n </div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:12px;\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm); line-height:1.4; margin:0;\">\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n </p>\n <div id=\"backup-status-container\"></div>\n </div>\n </div>\n ` : ''}\n </div>\n\n <!-- Right Column -->\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">System Color Theme</label>\n <select class=\"form-select\" id=\"system-theme-select\" style=\"width:100%\">\n ${Object.entries(THEMES).map(([key, val]) => `\n <option value=\"${key}\" ${currentTheme === key ? 'selected' : ''}>${val.name}</option>\n `).join('')}\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Select a color profile and visual background effect for your user interface.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', async (e) => {\n const select = e.target;\n select.disabled = true;\n try {\n const settings = store.getSettings();\n settings.tooltipPreference = select.value;\n await store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved successfully', 'success');\n } catch (err) {\n console.error('Error saving tooltip preference:', err);\n showToast('Failed to save preferences: ' + (err.message || err), 'error');\n } finally {\n select.disabled = false;\n }\n });\n\n tc.querySelector('#system-theme-select')?.addEventListener('change', (e) => {\n applyTheme(e.target.value, true);\n showToast('System theme updated successfully', 'success');\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a complete demonstration dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load a complete, realistic demonstration dataset representing an active trade business (5 customers, 5 quotes/jobs per customer, 5 assets per customer, 5 materials, 5 users/timesheets, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for testing and walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed Demonstration Data\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Demo Data\",\n className: \"btn-primary\",\n onClick: async (close) => {\n close();\n showToast('Seeding demonstration data...', 'info');\n await seedMinimalData();\n showToast('Demonstration data seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-delete-company')?.addEventListener('click', () => {\n // Modal Warning 1 of 2\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">warning</span>\n <span>You are about to permanently delete the entire company profile for <strong>${escapeHTML(store.getSettings().name || '')}</strong> and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).</span>\n </div>\n <p style=\"font-size:12.5px; color:var(--text-secondary); margin-bottom:12px\">\n This action is final, irreversible, and cannot be undone under any circumstances.\n </p>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Do you want to proceed to password confirmation?</p>\n `;\n\n showModal({\n title: \"Delete Company Profile - Step 1 of 2\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (closeModal) => closeModal()\n },\n {\n label: \"Proceed to Confirm\",\n className: \"btn-danger\",\n onClick: (closeModal) => {\n closeModal();\n \n // Modal Warning 2 of 2\n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <form id=\"delete-company-confirm-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <p style=\"margin-bottom:8px; font-weight:600; color:var(--color-danger)\">FINAL CONFIRMATION (2 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">gavel</span>\n <span>To authorize the permanent destruction of this company, you must enter the Administrator password.</span>\n </div>\n\n <div class=\"form-group\" style=\"margin-top:12px;\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Password</label>\n <input class=\"form-input\" type=\"password\" id=\"delete-confirm-password\" required placeholder=\"Enter admin password to proceed\" />\n </div>\n\n <div id=\"delete-confirm-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"delete-confirm-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-delete-abort\">Abort Deletion</button>\n <button type=\"submit\" class=\"btn btn-danger\" id=\"btn-delete-confirm-submit\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span>\n <span>Permanently Delete Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close: close2 } = showModal({\n title: \"⚠️ Confirm Company Deletion - Step 2 of 2\",\n content: content2,\n size: \"modal-md\"\n });\n\n content2.querySelector('#btn-delete-abort').addEventListener('click', close2);\n\n const form2 = content2.querySelector('#delete-company-confirm-form');\n form2.addEventListener('submit', async (ev) => {\n ev.preventDefault();\n\n const errorEl = content2.querySelector('#delete-confirm-error');\n const errorTextEl = content2.querySelector('#delete-confirm-error-text');\n const submitBtn = content2.querySelector('#btn-delete-confirm-submit');\n const abortBtn = content2.querySelector('#btn-delete-abort');\n const passwordInput = content2.querySelector('#delete-confirm-password').value;\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n abortBtn.disabled = true;\n const origSubmitText = submitBtn.innerHTML;\n submitBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Deleting...';\n\n try {\n let passwordVerified = false;\n\n if (isLocalMode) {\n if (localDeploymentType === 'single_user') {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acct = storedAccounts.find(a => a.id === activeAccountId);\n if (acct && acct.hasPassword) {\n const hashedInput = await hashPassword(passwordInput);\n if (hashedInput === acct.passwordHash) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n // No password set\n passwordVerified = true;\n }\n } else {\n // local_multiuser\n const techs = store.getAll('technicians') || [];\n const currentTech = techs.find(t => t.id === currentUser.id);\n const expectedPassword = currentTech ? currentTech.password : '123456';\n if (passwordInput === expectedPassword) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n }\n } else {\n // Cloud mode\n const { supabase } = await import('../utils/supabase.js');\n const { data: { user } } = await supabase.auth.getUser();\n if (user && user.email) {\n const { error } = await supabase.auth.signInWithPassword({\n email: user.email,\n password: passwordInput\n });\n if (!error) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n throw new Error('Could not retrieve logged-in cloud user credentials.');\n }\n }\n\n if (passwordVerified) {\n // 1. If cloud mode, delete the company row in Supabase\n if (!isLocalMode) {\n const { supabase } = await import('../utils/supabase.js');\n const { error: deleteErr } = await supabase.from('companies').delete().eq('id', store.companyId);\n if (deleteErr) {\n throw new Error('Failed to delete cloud company: ' + deleteErr.message);\n }\n }\n\n // 2. Trigger store.clearAll()\n store.clearAll();\n\n // 3. Clear local account launcher keys if local mode\n if (isLocalMode) {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const updatedAccounts = storedAccounts.filter(a => a.id !== activeAccountId);\n await storageSet('relay_accounts', updatedAccounts);\n }\n }\n\n // 4. Remove session user & active account\n localStorage.removeItem('currentUser');\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Company profile deleted successfully.', 'success');\n close2();\n\n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n } catch (err) {\n console.error('Company deletion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during verification.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n abortBtn.disabled = false;\n submitBtn.innerHTML = origSubmitText;\n }\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-convert-multiuser')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n\n const techs = store.getAll('technicians') || [];\n const companyId = store.companyId || '';\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const existingAdmin = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n const currentTechName = existingAdmin ? existingAdmin.name : (currentUser.name && currentUser.name !== 'Local Admin' ? currentUser.name : '');\n const currentTechUsernameOrEmail = existingAdmin ? (existingAdmin.email || existingAdmin.username || '') : '';\n\n content.innerHTML = `\n <form id=\"convert-multiuser-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Multi-User Local Network Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a single-user Local Admin profile.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"multiuser-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"e.g. Apex Power Services\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"multiuser-admin-name\" required value=\"${escapeHTML(currentTechName)}\" placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Email Address (Required)</label>\n <input class=\"form-input\" type=\"email\" id=\"multiuser-admin-email\" required value=\"${escapeHTML(currentTechUsernameOrEmail)}\" placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Admin Password</label>\n <input class=\"form-input\" type=\"password\" id=\"multiuser-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"multiuser-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"multiuser-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-multiuser-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-multiuser-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">lan</span>\n <span>Convert Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: \"Convert to Multi-User Local Sync\",\n content: content,\n size: \"modal-md\"\n });\n\n content.querySelector('#btn-multiuser-cancel').addEventListener('click', close);\n\n const form = content.querySelector('#convert-multiuser-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n const errorEl = content.querySelector('#multiuser-error');\n const errorTextEl = content.querySelector('#multiuser-error-text');\n const submitBtn = content.querySelector('#btn-multiuser-submit');\n const cancelBtn = content.querySelector('#btn-multiuser-cancel');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n\n const businessName = content.querySelector('#multiuser-company-name').value.trim();\n const adminName = content.querySelector('#multiuser-admin-name').value.trim();\n const adminEmail = content.querySelector('#multiuser-admin-email').value.trim();\n const adminPassword = content.querySelector('#multiuser-admin-password').value;\n\n try {\n if (!businessName || !adminName || !adminEmail || !adminPassword) {\n throw new Error('All fields are required.');\n }\n if (adminPassword.length < 6) {\n throw new Error('Password must be at least 6 characters.');\n }\n\n // 1. Update settings\n const settings = store.getSettings();\n settings.name = businessName;\n settings.localDeploymentType = 'multi_user';\n await store.saveSettings(settings);\n\n // 2. Update launcher accounts businessName\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acctIndex = storedAccounts.findIndex(a => a.id === activeAccountId);\n if (acctIndex !== -1) {\n storedAccounts[acctIndex].businessName = businessName;\n await storageSet('relay_accounts', storedAccounts);\n }\n }\n\n // 3. Create or update admin user in technicians\n const companyId = store.companyId;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const techs = store.getAll('technicians') || [];\n let adminTech = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n if (!adminTech) {\n adminTech = {\n id: `${companyId}_tech_admin_created`,\n role: 'Admin',\n color: '#FF5C00',\n userTypeId: adminTypeId,\n payRate: 100.00,\n phone: ''\n };\n techs.push(adminTech);\n }\n\n adminTech.name = adminName;\n adminTech.username = adminEmail.split('@')[0];\n adminTech.email = adminEmail;\n adminTech.password = adminPassword;\n\n store.save('technicians', techs);\n\n // 4. Update currentUser session in localStorage\n const localUser = {\n id: adminTech.id,\n companyId: companyId,\n name: adminTech.name,\n role: 'admin',\n userTypeName: 'Admin',\n userTypeId: adminTech.userTypeId,\n color: adminTech.color || '#FF5C00',\n theme: 'light'\n };\n localStorage.setItem('currentUser', JSON.stringify(localUser));\n\n showToast('Converted to Multi-User Local Network Sync. Reloading...', 'success');\n close();\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n setTimeout(() => window.location.reload(), 1200);\n } catch (err) {\n console.error('Conversion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during conversion.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n }\n });\n });\n\n tc.querySelector('#btn-convert-cloud-action')?.addEventListener('click', () => {\n openMigrationModal();\n });\n\n const renderBackupStatus = () => {\n const bsc = tc.querySelector('#backup-status-container');\n if (!bsc) return;\n\n const hasHandle = !!store.backupDirHandle;\n const isPermissionGranted = store.backupDirPermissionGranted;\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const formattedLastBackup = lastBackup ? new Date(lastBackup).toLocaleString() : 'Never';\n const isSupported = typeof window !== 'undefined' && window.showDirectoryPicker;\n\n if (!isSupported) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-danger); line-height:1.4;\">\n <strong style=\"display:block; margin-bottom:4px;\">Browser Local Folder Access Unsupported</strong>\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n </div>\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:12px; border-radius:6px; margin-bottom:12px; color:var(--text-secondary); display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary);\">folder_off</span>\n <div style=\"font-size:12.5px;\">No backup folder configured.</div>\n </div>\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">folder</span> Choose Backup Folder...\n </button>\n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-warning); line-height:1.4; margin-bottom:12px;\">\n <strong>Permission Required</strong><br/>\n Access permission to <strong>${escapeHTML(store.backupDirHandle.name)}</strong> has expired. Please re-authorize.\n </div>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-warning\" id=\"btn-backup-auth\" style=\"flex:1; justify-content:center;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize & Backup\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n `;\n } else {\n bsc.innerHTML = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-success); line-height:1.4; margin-bottom:12px;\">\n <strong>Backup Configured</strong><br/>\n Folder: <strong>${escapeHTML(store.backupDirHandle.name)}</strong><br/>\n Last Backup: <strong>${formattedLastBackup}</strong>\n </div>\n <div style=\"display:flex; flex-direction:column; gap:8px;\">\n <button class=\"btn btn-primary\" id=\"btn-backup-now\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">backup</span> Backup Now\n </button>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"flex:1; justify-content:center; border:1px solid var(--border-color);\">\n <span class=\"material-icons-outlined\">folder</span> Change Folder...\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n </div>\n `;\n }\n\n // Attach event listeners for backup buttons inside container\n bsc.querySelector('#btn-backup-pick')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setBackupDirectory(handle);\n showToast('Backup directory selected successfully.', 'success');\n showToast('Running initial backup...', 'info');\n await store.backupToFolder(handle);\n showToast('Backup completed successfully!', 'success');\n renderBackupStatus();\n } catch (err) {\n console.error('Failed to select backup directory:', err);\n if (err.name !== 'AbortError') {\n showToast('Failed to configure backup: ' + err.message, 'error');\n }\n }\n });\n\n bsc.querySelector('#btn-backup-auth')?.addEventListener('click', async () => {\n const granted = await store.verifyBackupDirPermission(true);\n if (granted) {\n showToast('Permission re-authorized. Backing up...', 'info');\n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n showToast('Backup failed: ' + err.message, 'error');\n }\n } else {\n showToast('Failed to acquire write permission.', 'error');\n }\n renderBackupStatus();\n });\n\n bsc.querySelector('#btn-backup-now')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Backing up...';\n \n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Backup failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n renderBackupStatus();\n }\n });\n\n bsc.querySelector('#btn-backup-disconnect')?.addEventListener('click', async () => {\n await store.setBackupDirectory(null);\n showToast('Backup folder disconnected.', 'info');\n renderBackupStatus();\n });\n };\n\n if (!isLocalMode && currentUser.role === 'admin') {\n renderBackupStatus();\n }\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', username: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Username</label>\n <input class=\"form-input\" id=\"u-username\" value=\"${t.username || (t.email ? t.email.split('@')[0] : '')}\" ${editId ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''} placeholder=\"e.g. joshua\" />\n ${!editId ? `\n <div style=\"font-size: 11px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500;\">\n Company login code is: <strong style=\"color: var(--color-primary)\">${companySlug}</strong>. User will log in with <strong style=\"color: var(--color-primary)\">username@${companySlug}</strong>\n </div>\n ` : ''}\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">${editId ? 'Reset Password (leave blank to keep current)' : 'Temporary Password (assigned for worker login)'}</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"${editId ? '••••••••' : 'Min. 6 characters'}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\" ${(t.userTypeId === 'ut_admin' || (t.userTypeId && t.userTypeId.endsWith('_ut_admin'))) ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''}>\n <option value=\"\">-- Select --</option>\n ${userTypes\n .filter(ut => (!ut.id.endsWith('_ut_admin') && ut.id !== 'ut_admin') || t.userTypeId === ut.id)\n .map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value.trim();\n const username = document.getElementById('u-username').value.trim();\n const role = document.getElementById('u-role').value.trim();\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = document.getElementById('u-password')?.value || '';\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!username) { showToast('Username required', 'error'); return; }\n if (username.includes('@')) { showToast('Username cannot contain @ symbol', 'error'); return; }\n \n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (password && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n const updates = { name, username, role, userTypeId, color, payRate };\n if (password) {\n updates.password = password;\n }\n\n if (editId) {\n await store.update('technicians', editId, updates);\n } else {\n await store.create('technicians', updates);\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = async () => {\n const btn = tc.querySelector('#btn-save-materials');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n await store.saveSettings(updatedSettings);\n showToast('Material settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving material settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderPaymentsTab(tc) {\n const settings = store.getSettings() || {};\n const pay = settings.payments || {};\n const isCloud = !!(store.companyId && !String(store.companyId).startsWith('acct_'));\n\n if (!isCloud) {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:760px\">\n <div class=\"card-header\"><h4>Online Payments (Stripe)</h4></div>\n <div class=\"card-body\">\n <p style=\"color:var(--text-secondary);\">Online card payments are a cloud feature. Upgrade to a cloud account to let customers pay invoices online by card.</p>\n </div>\n </div>`;\n return;\n }\n\n const enabledFor = pay.enabledFor || {};\n const currencies = ['AUD', 'USD', 'NZD', 'GBP', 'EUR'];\n const cur = (pay.currency || 'AUD').toUpperCase();\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:820px\">\n <div class=\"card-header\"><h4>Online Payments (Stripe)</h4></div>\n <div class=\"card-body\">\n <p style=\"color:var(--text-secondary);margin-top:0;\">\n Let customers pay invoices by card via a secure Stripe checkout link. Generate a link from any sent invoice; it's marked Paid automatically once payment clears.\n </p>\n\n <div style=\"background:var(--content-bg);border:1px solid var(--border-color);border-radius:8px;padding:14px 16px;margin:14px 0;\">\n <div style=\"font-weight:600;font-size:13px;margin-bottom:8px;\">One-time setup</div>\n <ol style=\"margin:0;padding-left:18px;font-size:12px;color:var(--text-secondary);line-height:1.7;\">\n <li>Create a <strong>Stripe</strong> account and copy your <strong>Secret key</strong>.</li>\n <li>In Supabase → Edge Functions → Secrets, add <code>STRIPE_SECRET_KEY</code> and <code>STRIPE_WEBHOOK_SECRET</code>.</li>\n <li>Deploy the <code>relay-create-payment</code> and <code>relay-stripe-webhook</code> functions.</li>\n <li>In Stripe → Developers → Webhooks, add an endpoint pointing at <code>relay-stripe-webhook</code> and subscribe it to <code>checkout.session.completed</code>.</li>\n </ol>\n </div>\n\n <div class=\"form-group\" style=\"display:flex;align-items:center;gap:10px;\">\n <label class=\"switch\" style=\"margin:0;\">\n <input type=\"checkbox\" id=\"pay-connected\" ${pay.connected ? 'checked' : ''} />\n </label>\n <div>\n <div style=\"font-weight:600;font-size:13px;\">Payments configured &amp; live</div>\n <div style=\"font-size:11px;color:var(--text-tertiary);\">Tick once the setup above is done. Controls whether the Pay Link button appears on invoices.</div>\n </div>\n </div>\n\n <div class=\"form-group\" style=\"max-width:220px;\">\n <label class=\"form-label\">Currency</label>\n <select class=\"form-input\" id=\"pay-currency\">\n ${currencies.map(c => `<option value=\"${c}\" ${c === cur ? 'selected' : ''}>${c}</option>`).join('')}\n </select>\n </div>\n\n <div class=\"form-group\" style=\"display:flex;align-items:center;gap:10px;\">\n <input type=\"checkbox\" id=\"pay-enable-invoice\" style=\"width:16px;height:16px;\" ${enabledFor.invoice !== false ? 'checked' : ''} />\n <label for=\"pay-enable-invoice\" style=\"margin:0;font-size:13px;\">Show a \"Pay Link\" action on sent invoices</label>\n </div>\n\n <div style=\"margin-top:16px;\">\n <button class=\"btn btn-primary\" id=\"pay-save\"><span class=\"material-icons-outlined\">save</span> Save Payment Settings</button>\n </div>\n </div>\n </div>`;\n\n tc.querySelector('#pay-save')?.addEventListener('click', async () => {\n try {\n const s = store.getSettings() || {};\n s.payments = {\n ...(s.payments || {}),\n connected: tc.querySelector('#pay-connected').checked,\n currency: tc.querySelector('#pay-currency').value,\n enabledFor: {\n ...((s.payments || {}).enabledFor || {}),\n invoice: tc.querySelector('#pay-enable-invoice').checked,\n },\n };\n await store.saveSettings(s);\n showToast('Payment settings saved', 'success');\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n } catch (err) {\n console.error('Error saving payment settings:', err);\n showToast('Could not save payment settings', 'error');\n }\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-00023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-00129',\n originalQuoteNumber: 'Q-00412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#FF5C00', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' },\n ballet: { name: 'Ballet Pointe', preset: 'ballet', accentColor: '#e58799', headerBg: '#5c2c36', accentTint: '#fff5f6', fontFamily: 'serif' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-company-name\" style=\"width:16px; height:16px\" ${dt.hideCompanyName ? 'checked' : ''} />\n <label for=\"theme-hide-company-name\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide company name text (use logo only)</label>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Variation</label>\n <select class=\"form-select\" id=\"theme-logo-source\" style=\"width:100%\">\n <option value=\"large\" ${dt.logoSource === 'large' ? 'selected' : ''}>Standard Logo (Large)</option>\n <option value=\"small\" ${dt.logoSource === 'small' ? 'selected' : ''}>Small / Shrunk Logo (Small)</option>\n </select>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:8px 0\"></div>\n <div class=\"form-group ${isLocalMode ? 'disabled-local' : ''}\" ${isLocalMode ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''} style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-brand-logo\" style=\"width:16px; height:16px\" ${dt.hideBrandLogo ? 'checked' : ''} ${isLocalMode ? 'disabled' : ''} />\n <label for=\"theme-hide-brand-logo\" class=\"form-label\" style=\"margin:0; cursor:${isLocalMode ? 'not-allowed' : 'pointer'}\">Remove Relay-dispatch brand logo from document footers</label>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n <div class=\"form-row\" style=\"display:flex; gap:12px\">\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Invoice Prefix</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoicePrefix\" value=\"${escapeHTML(dt.invoicePrefix || 'INV-')}\" placeholder=\"INV-\" />\n </div>\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Invoice Starting Number</label>\n <input type=\"number\" class=\"form-input text-copy-input\" data-prop=\"invoiceStartingNumber\" value=\"${dt.invoiceStartingNumber !== undefined ? dt.invoiceStartingNumber : 1}\" min=\"1\" placeholder=\"1\" />\n </div>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n <div class=\"form-row\" style=\"display:flex; gap:12px\">\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Quote Prefix</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quotePrefix\" value=\"${escapeHTML(dt.quotePrefix || 'Q-')}\" placeholder=\"Q-\" />\n </div>\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Quote Starting Number</label>\n <input type=\"number\" class=\"form-input text-copy-input\" data-prop=\"quoteStartingNumber\" value=\"${dt.quoteStartingNumber !== undefined ? dt.quoteStartingNumber : 1}\" min=\"1\" placeholder=\"1\" />\n </div>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Hide Company Name toggle\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) {\n hideCompanyNameChk.addEventListener('change', () => {\n dt.hideCompanyName = hideCompanyNameChk.checked;\n updatePreview();\n });\n }\n\n // Logo Variation toggle\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) {\n logoSourceSelect.addEventListener('change', (e) => {\n dt.logoSource = e.target.value;\n updatePreview();\n });\n }\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Hide Brand Logo toggle\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) {\n hideBrandLogoChk.addEventListener('change', () => {\n dt.hideBrandLogo = hideBrandLogoChk.checked;\n updatePreview();\n });\n }\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-theme-save');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n await store.saveSettings(freshSettings);\n showToast('Document customization settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving theme settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n hideCompanyName: false,\n hideBrandLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) hideCompanyNameChk.checked = !!dt.hideCompanyName;\n\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) logoSourceSelect.value = dt.logoSource || 'large';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePrefixInp = tc.querySelector('[data-prop=\"invoicePrefix\"]');\n if (invoicePrefixInp) invoicePrefixInp.value = dt.invoicePrefix || '';\n\n const invoiceStartingNumberInp = tc.querySelector('[data-prop=\"invoiceStartingNumber\"]');\n if (invoiceStartingNumberInp) invoiceStartingNumberInp.value = dt.invoiceStartingNumber !== undefined ? dt.invoiceStartingNumber : 1;\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const quotePrefixInp = tc.querySelector('[data-prop=\"quotePrefix\"]');\n if (quotePrefixInp) quotePrefixInp.value = dt.quotePrefix || '';\n\n const quoteStartingNumberInp = tc.querySelector('[data-prop=\"quoteStartingNumber\"]');\n if (quoteStartingNumberInp) quoteStartingNumberInp.value = dt.quoteStartingNumber !== undefined ? dt.quoteStartingNumber : 1;\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) hideBrandLogoChk.checked = !!dt.hideBrandLogo;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n const logoUrl = dt.logoSource === 'small' ? (settings.logoSmall || settings.logo) : (settings.logo || settings.logoSmall);\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.src = logoUrl || '';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // Company Name visibility\n const companyNameEl = frameDoc.querySelector('.pdf-company-name');\n if (companyNameEl) {\n companyNameEl.style.display = dt.hideCompanyName ? 'none' : 'block';\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n\n const footerBrandingEl = frameDoc.querySelector('.pdf-footer-branding');\n if (footerBrandingEl) {\n const hideBrandLogo = isLocalMode ? false : !!dt.hideBrandLogo;\n footerBrandingEl.style.display = hideBrandLogo ? 'none' : 'flex';\n }\n }\n\n renderMarkup();\n }\n\n function renderFolderSyncTab(tc) {\n const isSupported = typeof window !== 'undefined' && (\n (window.indexedDB && window.showDirectoryPicker) ||\n (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem)\n );\n\n const isCapacitor = typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem;\n\n const activeAccountId = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n const isLocalCompany = !!activeAccountId;\n\n function render() {\n console.log('[DEBUG Settings] render: isLocalCompany =', isLocalCompany, 'store.dirHandle =', store.dirHandle, 'permissionGranted =', store.folderSyncPermissionGranted);\n const isEnabled = isLocalCompany ? true : store.folderSyncEnabled;\n const isPermissionGranted = store.folderSyncPermissionGranted;\n const hasHandle = !!store.dirHandle;\n\n let statusHtml = '';\n if (!isSupported) {\n statusHtml = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:16px; border-radius:4px; color:var(--color-danger); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:8px; font-weight:600; margin-bottom:4px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Browser Directory Access Unsupported</span>\n </div>\n <p style=\"font-size:var(--font-size-sm); margin:0; line-height:1.4;\">\n Your current browser does not support local folder access. To enable direct folder synchronization, please run this app in a Chromium-based browser (Chrome, Edge, Opera, or as a compiled native app). High-capacity IndexedDB storage remains active.\n </p>\n </div>\n `;\n } else if (!isEnabled) {\n statusHtml = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:16px; border-radius:6px; color:var(--text-secondary); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px; color:var(--text-tertiary);\">folder_off</span>\n <div>\n <div style=\"font-weight:600; color:var(--text-primary); margin-bottom:2px;\">Folder Synchronization Inactive</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4;\">All data is currently stored in your browser's private IndexedDB database sandbox.</p>\n </div>\n </div>\n `;\n } else if (isCapacitor) {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">cloud_done</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Capacitor Direct Folder Sync Active</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Data is synchronizing directly to the application's native <strong>Documents/RelayDispatchData</strong> directory on your iPad/device.\n </p>\n </div>\n </div>\n `;\n } else if (!hasHandle) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">warning</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">No Directory Selected</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">Please select a directory folder on your computer to begin syncing.</p>\n </div>\n </div>\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:12px; margin-bottom:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">lock</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Access Permission Suspended</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n The browser requires re-authorization to read/write files in <strong>${escapeHTML(store.dirHandle.name)}</strong>.\n </p>\n </div>\n </div>\n <button class=\"btn btn-warning\" id=\"btn-reauthorize-dir\" style=\"margin-left:44px;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize Access\n </button>\n </div>\n `;\n } else {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">check_circle</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Folder Sync Active & Synchronized</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Active Folder: <strong>${escapeHTML(store.dirHandle.name)}</strong>. All data edits are writing dynamically.\n </p>\n </div>\n </div>\n `;\n }\n\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <!-- Folder Configuration -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Direct Directory Sync</h4></div>\n <div class=\"card-body\">\n ${statusHtml}\n\n <div class=\"form-group\" style=\"margin-bottom:var(--space-lg);\">\n <label class=\"form-label\" style=\"font-weight:600; margin-bottom:8px;\">Synchronization Toggle</label>\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\">\n <input type=\"checkbox\" id=\"toggle-folder-sync\" ${isEnabled ? 'checked' : ''} ${(!isSupported || isLocalCompany) ? 'disabled' : ''} style=\"width:20px; height:20px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\" />\n <div>\n <span style=\"font-weight:500;\">Enable Direct Local Folder Storage</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">\n ${isLocalCompany ? 'Mandatory for offline/local company profile storage.' : 'Saves all data records and attachments straight to your machine.'}\n </div>\n </div>\n </label>\n </div>\n\n ${isEnabled && isSupported && !isCapacitor ? `\n <div style=\"display:flex; gap:12px; flex-wrap:wrap; border-top:1px solid var(--border-color); padding-top:var(--space-lg);\">\n <button class=\"btn btn-secondary\" id=\"btn-pick-dir\">\n <span class=\"material-icons-outlined\">folder</span> Choose Sync Folder...\n </button>\n ${hasHandle && isPermissionGranted ? `\n <button class=\"btn btn-secondary\" id=\"btn-force-sync\" data-tooltip=\"Overwrite folder JSONs with current memory cache\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">sync</span> Sync Now\n </button>\n ${!isLocalCompany ? `\n <button class=\"btn btn-danger\" id=\"btn-disconnect-dir\">\n <span class=\"material-icons-outlined\">link_off</span> Disconnect Folder\n </button>\n ` : ''}\n ` : ''}\n </div>\n ` : ''}\n </div>\n </div>\n\n <!-- Instructions Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>How Self-Hosting Works</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p>\n By linking a local folder, you establish a <strong>serverless self-hosted data hub</strong>.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">storage</span>\n <span><strong>Readable Data:</strong> Your jobs, quotes, and company configurations are saved in <code>data/*.json</code> files.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">photo_library</span>\n <span><strong>Physical Attachments:</strong> Uploaded photos and PDF catalogs are saved as standard image/PDF files under the <code>documents/</code> subfolder.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">sync_alt</span>\n <span><strong>Cloud Backups:</strong> Select a folder synced to <strong>OneDrive, Google Drive, or Dropbox</strong> to automatically back up and sync your data to the cloud.</span>\n </div>\n </div>\n </div>\n </div>\n `;\n\n // Event Listeners\n const toggle = tc.querySelector('#toggle-folder-sync');\n toggle?.addEventListener('change', async (e) => {\n if (isLocalCompany) {\n e.preventDefault();\n toggle.checked = true;\n return;\n }\n\n if (isCapacitor) {\n await store.setLocalDirectory(e.target.checked ? {} : null);\n showToast(e.target.checked ? 'Capacitor folder sync activated' : 'Folder sync deactivated', 'success');\n render();\n return;\n }\n\n if (e.target.checked) {\n // Trigger directory picker\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory configured successfully', 'success');\n } catch (err) {\n console.error('Directory selection cancelled or failed:', err);\n toggle.checked = false;\n showToast('Folder selection cancelled', 'info');\n }\n } else {\n await store.setLocalDirectory(null);\n showToast('Direct directory synchronization disabled', 'info');\n }\n render();\n });\n\n tc.querySelector('#btn-pick-dir')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory updated successfully', 'success');\n render();\n } catch (err) {\n console.error(err);\n }\n });\n\n tc.querySelector('#btn-reauthorize-dir')?.addEventListener('click', async () => {\n const granted = await store.verifyDirPermission(true);\n if (granted) {\n showToast('Folder access re-authorized successfully', 'success');\n } else {\n showToast('Failed to acquire write permissions', 'error');\n }\n render();\n });\n\n tc.querySelector('#btn-disconnect-dir')?.addEventListener('click', async () => {\n await store.setLocalDirectory(null);\n showToast('Local folder disconnected', 'info');\n render();\n });\n\n tc.querySelector('#btn-force-sync')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n btn.disabled = true;\n showToast('Writing cache data to local directory...', 'info');\n try {\n const collections = Object.keys(store.cache);\n await Promise.all(collections.map(col => store.writeCollectionToFolder(col, store.cache[col])));\n showToast('Folder sync completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Folder sync failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n }\n });\n }\n\n render();\n }\n\n function renderApiKeysTab(tc) {\n const s = store.getSettings();\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const ai = s.ai || {\n enabled: false,\n apiKey: '',\n visionApiKey: '',\n useSameKey: true,\n endpoint: 'https://api.deepseek.com/chat/completions',\n model: 'deepseek-chat',\n visionEndpoint: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',\n visionModel: 'gemini-2.0-flash',\n systemPrompt: 'You are Deputy, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n };\n if (ai.useSameKey === undefined) ai.useSameKey = true;\n const maps = s.maps || { apiKey: '' };\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null') || { id: 'default' };\n const userId = currentUser.id || 'default';\n const factsheetKey = `relay_factsheet_${userId}`;\n const enabledKey = `relay_factsheet_enabled_${userId}`;\n const factsheetVal = localStorage.getItem(factsheetKey) || '';\n const memoryEnabled = localStorage.getItem(enabledKey) !== 'false';\n\n function render() {\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg);\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>AI Assistant Configuration</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n \n <div class=\"form-group\">\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:pointer;\">\n <input type=\"checkbox\" id=\"ai-enabled\" ${ai.enabled ? 'checked' : ''} style=\"width:20px; height:20px; cursor:pointer;\" />\n <div>\n <span style=\"font-weight:600;\">Enable AI Assistant</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">Replaces the basic rule-based Relay co-pilot with a smart conversational LLM.</div>\n </div>\n </label>\n </div>\n\n <div id=\"ai-fields\" style=\"display: ${ai.enabled ? 'flex' : 'none'}; flex-direction:column; gap:16px; border-top:1px solid var(--border-color); padding-top:16px;\">\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Text API Key</label>\n <input type=\"password\" class=\"form-input\" id=\"ai-apikey\" value=\"${ai.apiKey || ''}\" placeholder=\"${isLocalMode ? 'sk-...' : 'Optional — Defaults to secure cloud API key'}\" />\n </div>\n <div class=\"form-group\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <label class=\"form-label\" style=\"font-weight:600; margin-bottom:0;\">Vision API Key</label>\n <label style=\"display:flex; align-items:center; gap:6px; font-size:12px; cursor:pointer;\">\n <input type=\"checkbox\" id=\"ai-use-same-key\" ${ai.useSameKey ? 'checked' : ''} style=\"width:14px; height:14px;\" /> Use Text API Key\n </label>\n </div>\n <input type=\"password\" class=\"form-input\" id=\"ai-vision-apikey\" value=\"${ai.useSameKey ? (ai.apiKey || '') : (ai.visionApiKey || '')}\" placeholder=\"API Key for Vision Model\" style=\"margin-top:6px; ${ai.useSameKey ? 'opacity:0.5; pointer-events:none;' : ''}\" />\n </div>\n </div>\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:-8px;\">${isLocalMode ? 'Your API keys are stored locally in your browser/sync directory.' : 'Keys are optional. If blank, cloud operations will route securely through Edge Functions.'}</div>\n\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Text API Endpoint</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-endpoint\" value=\"${ai.endpoint || 'https://api.deepseek.com/chat/completions'}\" placeholder=\"https://api.deepseek.com/chat/completions\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Text Model Name</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-model\" value=\"${ai.model || 'deepseek-chat'}\" placeholder=\"deepseek-chat\" />\n </div>\n </div>\n\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Vision API Endpoint (Multimodal)</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-vision-endpoint\" value=\"${ai.visionEndpoint || 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions'}\" placeholder=\"https://api.openai.com/v1/chat/completions\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Vision Model Name</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-vision-model\" value=\"${ai.visionModel || 'gemini-2.0-flash'}\" placeholder=\"gpt-4o-mini\" />\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Base System Prompt</label>\n <textarea class=\"form-input\" id=\"ai-systemprompt\" rows=\"4\" style=\"font-family:inherit; resize:vertical;\">${ai.systemPrompt || ''}</textarea>\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.</div>\n </div>\n\n <div style=\"display:flex; gap:12px; align-items:center; margin-top:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-test-ai\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">bolt</span> Test Connection\n </button>\n <span id=\"test-status\" style=\"font-size:13px; font-weight:500;\"></span>\n </div>\n <div id=\"test-error-details\" style=\"display:none; margin-top:8px; font-size:12px; color:var(--color-danger); background:var(--color-danger-bg); border-left:3px solid var(--color-danger); padding:8px; border-radius:4px; line-height:1.4;\"></div>\n </div>\n\n <div style=\"border-top:1px solid var(--border-color); padding-top:16px; display:flex; justify-content:flex-end;\">\n <button class=\"btn btn-primary\" id=\"btn-save-ai\">Save AI Config</button>\n </div>\n\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Google Maps Configuration</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Maps API Key</label>\n <input type=\"password\" class=\"form-input\" id=\"maps-apikey\" value=\"${maps.apiKey || ''}\" placeholder=\"AIzaSy...\" />\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">Used for address autocomplete, distance calculations, and rendering maps. Get this from the Google Cloud Console.</div>\n </div>\n <div style=\"border-top:1px solid var(--border-color); padding-top:16px; display:flex; justify-content:flex-end;\">\n <button class=\"btn btn-primary\" id=\"btn-save-maps\">Save Maps Config</button>\n </div>\n </div>\n </div>\n\n </div>\n\n <!-- Cheapest Smart AI Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>Cheapest Smart AI Guide</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p style=\"margin:0;\">\n <strong>DeepSeek</strong> is one of the most cost-efficient and high-performance language models currently available, making it perfect for lightweight co-pilot tasks.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">key</span>\n <span><strong>API Key:</strong> Get your key from <a href=\"https://platform.deepseek.com/\" target=\"_blank\" style=\"color:var(--color-primary); text-decoration:underline;\">platform.deepseek.com</a>. New accounts typically receive free trial credits.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">security</span>\n <span><strong>CORS Restrictions:</strong> Due to browser security, direct API calls to DeepSeek may be blocked. To bypass this:\n <ul style=\"margin:4px 0 0 16px; padding:0; list-style:disc; font-size:12px;\">\n <li>Route via a CORS proxy (e.g. <code>https://cors-anywhere.herokuapp.com/</code> prefix).</li>\n <li>Connect via an aggregator like <strong>OpenRouter</strong> (API Endpoint: <code>https://openrouter.ai/api/v1/chat/completions</code>).</li>\n <li>Use a local LLM runner like <strong>Ollama</strong> (API Endpoint: <code>http://localhost:11434/v1/chat/completions</code>).</li>\n </ul>\n </span>\n </div>\n </div>\n </div>\n\n </div>\n `;\n\n // Toggle display of fields based on enabled checkbox\n const enabledCheckbox = tc.querySelector('#ai-enabled');\n const fieldsContainer = tc.querySelector('#ai-fields');\n enabledCheckbox.addEventListener('change', (e) => {\n fieldsContainer.style.display = e.target.checked ? 'flex' : 'none';\n });\n\n const useSameKeyCheckbox = tc.querySelector('#ai-use-same-key');\n const visionApiKeyInput = tc.querySelector('#ai-vision-apikey');\n const textApiKeyInput = tc.querySelector('#ai-apikey');\n if (useSameKeyCheckbox && visionApiKeyInput) {\n useSameKeyCheckbox.addEventListener('change', (e) => {\n if (e.target.checked) {\n visionApiKeyInput.style.opacity = '0.5';\n visionApiKeyInput.style.pointerEvents = 'none';\n if (textApiKeyInput) visionApiKeyInput.value = textApiKeyInput.value;\n } else {\n visionApiKeyInput.style.opacity = '1';\n visionApiKeyInput.style.pointerEvents = 'auto';\n visionApiKeyInput.focus();\n }\n });\n \n if (textApiKeyInput) {\n textApiKeyInput.addEventListener('input', (e) => {\n if (useSameKeyCheckbox.checked) {\n visionApiKeyInput.value = e.target.value;\n }\n });\n }\n }\n\n\n\n // Save handler\n tc.querySelector('#btn-save-ai').addEventListener('click', () => {\n const enabled = enabledCheckbox.checked;\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const useSameKey = tc.querySelector('#ai-use-same-key') ? tc.querySelector('#ai-use-same-key').checked : true;\n const visionApiKey = useSameKey ? apiKey : tc.querySelector('#ai-vision-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const visionEndpoint = tc.querySelector('#ai-vision-endpoint').value.trim();\n const visionModel = tc.querySelector('#ai-vision-model').value.trim();\n const systemPrompt = tc.querySelector('#ai-systemprompt').value.trim();\n\n if (isLocalMode && enabled && !apiKey) {\n showToast('Text API Key is required when enabling the AI assistant.', 'error');\n return;\n }\n\n const settings = store.getSettings();\n settings.ai = {\n enabled,\n apiKey,\n visionApiKey,\n useSameKey,\n endpoint,\n model,\n visionEndpoint,\n visionModel,\n systemPrompt\n };\n\n store.saveSettings(settings);\n showToast('AI settings saved successfully', 'success');\n });\n\n tc.querySelector('#btn-save-maps').addEventListener('click', () => {\n const apiKey = tc.querySelector('#maps-apikey').value.trim();\n const settings = store.getSettings();\n settings.maps = { apiKey };\n store.saveSettings(settings);\n showToast('Maps settings saved successfully', 'success');\n });\n\n // Test handler\n tc.querySelector('#btn-test-ai').addEventListener('click', async () => {\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const statusEl = tc.querySelector('#test-status');\n const errEl = tc.querySelector('#test-error-details');\n\n const inElectron = !!(window.electronAPI && window.electronAPI.callDeepSeek);\n const hasEnvKey = !!(import.meta.env.VITE_DEEPSEEK_API_KEY);\n\n if (!apiKey && !inElectron && !hasEnvKey) {\n showToast('Enter an API key to test.', 'error');\n return;\n }\n\n statusEl.style.color = 'var(--text-secondary)';\n statusEl.textContent = 'Testing connection...';\n errEl.style.display = 'none';\n\n const startTime = Date.now();\n try {\n if (inElectron) {\n const data = await window.electronAPI.callDeepSeek({\n messages: [{ role: 'user', content: 'respond only with the word OK' }],\n endpoint,\n model,\n apiKey // Pass typed key if any\n });\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success (via Electron IPC)! Latency: ${latency}ms (Reply: \"${reply}\")`;\n } else {\n const res = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY}`\n },\n body: JSON.stringify({\n model: model || 'deepseek-chat',\n messages: [\n { role: 'user', content: 'respond only with the word OK' }\n ],\n max_tokens: 5\n })\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`HTTP ${res.status}: ${body}`);\n }\n\n const data = await res.json();\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success! Latency: ${latency}ms (Reply: \"${reply}\")`;\n }\n } catch (err) {\n console.error('AI test connection failed:', err);\n statusEl.style.color = 'var(--color-danger)';\n statusEl.textContent = 'Test failed';\n errEl.style.display = 'block';\n\n let errMsg = err.message;\n if (err.name === 'TypeError' && err.message.includes('Failed to fetch')) {\n errMsg = 'CORS Blocked or Offline. The browser blocked this cross-origin request. Please use a CORS proxy, an API gateway like OpenRouter, or check your internet connection.';\n }\n errEl.textContent = errMsg;\n }\n });\n }\n\n render();\n }\n\n function renderCostCentersTab(tc) {\n const list = store.getAll('costCenters') || [];\n\n const renderTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:900px\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Cost Centers</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-cost-center\" style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span> Add Cost Center\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"padding-left:16px; width:120px\">Code</th>\n <th>Name</th>\n <th>Xero Mapping</th>\n <th style=\"width:120px\">Status</th>\n <th style=\"text-align:right; padding-right:16px; width:180px\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${list.length === 0 ? `\n <tr>\n <td colspan=\"5\" style=\"text-align:center; padding:32px 16px; color:var(--text-secondary)\">\n No Cost Centers configured. Click \"Add Cost Center\" to create one.\n </td>\n </tr>\n ` : list.map(cc => `\n <tr>\n <td style=\"padding-left:16px; font-weight:600\">${escapeHTML(cc.code)}</td>\n <td>${escapeHTML(cc.name)}</td>\n <td>\n ${cc.xeroSalesAccountCode || cc.xeroTrackingOptionName ? `\n <div style=\"font-size:11px; line-height:1.4\">\n ${cc.xeroSalesAccountCode ? `<div><span class=\"text-tertiary\">Sales Code:</span> <strong>${escapeHTML(cc.xeroSalesAccountCode)}</strong></div>` : ''}\n ${cc.xeroExpenseAccountCode ? `<div><span class=\"text-tertiary\">Expense Code:</span> <strong>${escapeHTML(cc.xeroExpenseAccountCode)}</strong></div>` : ''}\n ${cc.xeroTrackingOptionName ? `<div><span class=\"text-tertiary\">Tracking:</span> <strong>${escapeHTML(cc.xeroTrackingCategoryName || 'Department')}:${escapeHTML(cc.xeroTrackingOptionName)}</strong></div>` : ''}\n </div>\n ` : '<span class=\"text-tertiary\" style=\"font-size:11px\">— Unmapped —</span>'}\n </td>\n <td>\n <span class=\"badge ${cc.active ? 'badge-success' : 'badge-neutral'}\">\n ${cc.active ? 'Active' : 'Inactive'}\n </span>\n </td>\n <td style=\"text-align:right; padding-right:16px\">\n <button class=\"btn btn-secondary btn-sm btn-edit-cc\" data-id=\"${cc.id}\" style=\"margin-right:6px\">Edit</button>\n <button class=\"btn btn-outline btn-sm btn-toggle-cc\" data-id=\"${cc.id}\">\n ${cc.active ? 'Disable' : 'Enable'}\n </button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-cost-center')?.addEventListener('click', () => openCostCenterModal());\n tc.querySelectorAll('.btn-edit-cc').forEach(btn => {\n btn.addEventListener('click', (e) => openCostCenterModal(e.target.dataset.id));\n });\n tc.querySelectorAll('.btn-toggle-cc').forEach(btn => {\n btn.addEventListener('click', async (e) => {\n const id = e.target.dataset.id;\n const cc = store.getById('costCenters', id);\n if (cc) {\n const newActive = !cc.active;\n await store.update('costCenters', id, { active: newActive });\n showToast(`Cost center ${cc.name} has been ${newActive ? 'enabled' : 'disabled'}.`, 'success');\n renderCostCentersTab(tc);\n }\n });\n });\n };\n\n renderTab();\n }\n\n function openCostCenterModal(editId = null) {\n let cc = editId ? store.getById('costCenters', editId) : { \n code: '', \n name: '', \n active: true,\n xeroSalesAccountCode: '',\n xeroExpenseAccountCode: '',\n xeroTrackingCategoryName: 'Department',\n xeroTrackingOptionName: ''\n };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\" style=\"margin-bottom:16px\">\n <label class=\"form-label\" style=\"display:block; margin-bottom:6px\">Cost Center Code</label>\n <input class=\"form-input\" id=\"cc-code\" value=\"${escapeHTML(cc.code)}\" placeholder=\"e.g. ELEC\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\" style=\"margin-bottom:16px\">\n <label class=\"form-label\" style=\"display:block; margin-bottom:6px\">Cost Center Name</label>\n <input class=\"form-input\" id=\"cc-name\" value=\"${escapeHTML(cc.name)}\" placeholder=\"e.g. Electrical Services\" style=\"width:100%\" />\n </div>\n \n <fieldset style=\"border: 1px solid var(--border-color); border-radius: 6px; padding: 12px; margin-bottom: 16px; background:var(--card-bg)\">\n <legend style=\"padding: 0 6px; font-size: 11px; font-weight: 600; color: var(--color-primary); display: flex; align-items: center; gap: 4px; margin: 0\">\n <span class=\"material-icons-outlined\" style=\"font-size:15px\">sync</span> Xero Integration (Optional)\n </legend>\n <div class=\"form-row\" style=\"margin-bottom:12px; display:grid; grid-template-columns:1fr 1fr; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Sales Account Code</label>\n <input class=\"form-input\" id=\"cc-xero-sales\" value=\"${escapeHTML(cc.xeroSalesAccountCode || '')}\" placeholder=\"e.g. 200\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Expense Account Code</label>\n <input class=\"form-input\" id=\"cc-xero-expense\" value=\"${escapeHTML(cc.xeroExpenseAccountCode || '')}\" placeholder=\"e.g. 300\" style=\"width:100%\" />\n </div>\n </div>\n <div class=\"form-row\" style=\"display:grid; grid-template-columns:1fr 1fr; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Tracking Category</label>\n <input class=\"form-input\" id=\"cc-xero-category\" value=\"${escapeHTML(cc.xeroTrackingCategoryName || 'Department')}\" placeholder=\"e.g. Department\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Tracking Option</label>\n <input class=\"form-input\" id=\"cc-xero-option\" value=\"${escapeHTML(cc.xeroTrackingOptionName || '')}\" placeholder=\"e.g. Electrical\" style=\"width:100%\" />\n </div>\n </div>\n </fieldset>\n\n <div class=\"form-group\">\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer\">\n <input type=\"checkbox\" id=\"cc-active\" ${cc.active ? 'checked' : ''} style=\"width:16px; height:16px; margin:0\" />\n <span>Active</span>\n </label>\n </div>\n `;\n\n showModal({\n title: editId ? 'Edit Cost Center' : 'Add Cost Center',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-cc', onClick: async (c) => {\n const code = document.getElementById('cc-code').value.trim().toUpperCase();\n const name = document.getElementById('cc-name').value.trim();\n const active = document.getElementById('cc-active').checked;\n const xeroSalesAccountCode = document.getElementById('cc-xero-sales').value.trim();\n const xeroExpenseAccountCode = document.getElementById('cc-xero-expense').value.trim();\n const xeroTrackingCategoryName = document.getElementById('cc-xero-category').value.trim();\n const xeroTrackingOptionName = document.getElementById('cc-xero-option').value.trim();\n\n if (!code) { showToast('Code required', 'error'); return; }\n if (!name) { showToast('Name required', 'error'); return; }\n\n try {\n const updates = { \n code, \n name, \n active,\n xeroSalesAccountCode: xeroSalesAccountCode || null,\n xeroExpenseAccountCode: xeroExpenseAccountCode || null,\n xeroTrackingCategoryName: xeroTrackingCategoryName || null,\n xeroTrackingOptionName: xeroTrackingOptionName || null\n };\n if (editId) {\n await store.update('costCenters', editId, updates);\n showToast('Cost Center updated successfully', 'success');\n } else {\n await store.create('costCenters', updates);\n showToast('Cost Center created successfully', 'success');\n }\n c();\n const tc = document.querySelector('#settings-content');\n renderCostCentersTab(tc);\n } catch (err) {\n console.error('Error saving cost center:', err);\n showToast('Failed to save cost center: ' + err.message, 'error');\n }\n }}\n ]\n });\n }\n\n function renderSuppliersSettings(tc) {\n const settings = store.getSettings();\n const categories = settings.supplierCategories || ['Electrical', 'Plumbing', 'HVAC', 'Fire Safety', 'Security', 'General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Supplier Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Define classifications/categories for your suppliers (e.g. Electrical, Plumbing, HVAC). These categories are used to group suppliers in the Suppliers directory.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"supplier-categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${escapeHTML(c)}\n <span class=\"material-icons-outlined btn-remove-supplier-cat\" data-name=\"${escapeHTML(c)}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-supplier-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-suppliers\" data-tooltip=\"Save supplier categories settings\" data-tooltip-pos=\"top\">Save Supplier Settings</button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-supplier-category').addEventListener('click', () => {\n const name = prompt('Enter supplier category name:');\n if (name) {\n const trimmed = name.trim();\n if (!trimmed) return;\n const exists = Array.from(tc.querySelectorAll('.btn-remove-supplier-cat')).some(span => span.dataset.name.toLowerCase() === trimmed.toLowerCase());\n if (exists) {\n showToast('Category already exists', 'error');\n return;\n }\n\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${escapeHTML(trimmed)}\n <span class=\"material-icons-outlined btn-remove-supplier-cat\" data-name=\"${escapeHTML(trimmed)}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#supplier-categories-container').insertBefore(btn, tc.querySelector('#btn-add-supplier-category'));\n btn.querySelector('.btn-remove-supplier-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-supplier-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-suppliers').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-suppliers');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const supplierCategories = Array.from(tc.querySelectorAll('.btn-remove-supplier-cat')).map(span => span.dataset.name);\n const updatedSettings = {\n ...settings,\n supplierCategories\n };\n await store.saveSettings(updatedSettings);\n showToast('Supplier settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving supplier settings:', err);\n showToast('Failed to save supplier settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n }\n"}, "1e11ec02c0e5d4f8702b000e47ed0b6a2cc9bbb0": {"t": 1785754173.0000203, "n": 285185, "h": "dd7e8c68c357d6d7", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData, seedData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\nimport { FLAGS } from '../utils/flags.js';\nimport { applyTheme, THEMES } from '../utils/theme.js';\nimport { storageGet, storageSet } from '../utils/tauriStore.js';\n\n// Compress uploaded images using Canvas to avoid huge Base64 data payloads\nfunction compressImage(dataUrl, maxWidth, maxHeight) {\n return new Promise((resolve) => {\n if (!dataUrl) {\n resolve(dataUrl);\n return;\n }\n // SVG vectors are already lightweight; do not compress them\n if (dataUrl.startsWith('data:image/svg+xml')) {\n resolve(dataUrl);\n return;\n }\n const img = new Image();\n img.onload = () => {\n let width = img.width;\n let height = img.height;\n\n if (width > height) {\n if (width > maxWidth) {\n height = Math.round((height * maxWidth) / width);\n width = maxWidth;\n }\n } else {\n if (height > maxHeight) {\n width = Math.round((width * maxHeight) / height);\n height = maxHeight;\n }\n }\n\n const canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n\n const ctx = canvas.getContext('2d');\n ctx.drawImage(img, 0, 0, width, height);\n\n const isPng = dataUrl.includes('image/png');\n const format = isPng ? 'image/png' : 'image/jpeg';\n const quality = isPng ? undefined : 0.85;\n\n resolve(canvas.toDataURL(format, quality));\n };\n img.onerror = () => {\n resolve(dataUrl);\n };\n img.src = dataUrl;\n });\n}\n\n// Helper to hash password using SHA-256 Web Crypto API\nasync function hashPassword(password) {\n const msgBuffer = new TextEncoder().encode(password);\n const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);\n const hashArray = Array.from(new Uint8Array(hashBuffer));\n return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');\n}\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n const localDeploymentType = settings.localDeploymentType || 'single_user';\n\n const isUsersDisabled = isLocalMode && localDeploymentType === 'single_user';\n const isPortalDisabled = isLocalMode;\n const isFolderSyncDisabled = !isLocalMode;\n\n if (isUsersDisabled && activeTab === 'users') {\n activeTab = 'company';\n }\n if (isPortalDisabled && activeTab === 'portal') {\n activeTab = 'company';\n }\n if (isPortalDisabled && activeTab === 'portal_contractor') {\n activeTab = 'company';\n }\n if (isFolderSyncDisabled && activeTab === 'folder_sync') {\n activeTab = 'company';\n }\n \n const isAIAssistantDisabled = isLocalMode;\n if (isAIAssistantDisabled && activeTab === 'ai_assistant') {\n activeTab = 'company';\n }\n\n function getCategoryForTab(tab) {\n if (['company', 'portal', 'portal_contractor', 'folder_sync', 'ai_assistant', 'system'].includes(tab)) return 'general';\n if (['templates_forms', 'invoices_quotes', 'payments'].includes(tab)) return 'workflow';\n if (['users', 'suppliers'].includes(tab)) return 'people';\n if (['materials', 'cost_centers', 'tax'].includes(tab)) return 'resources';\n return 'general';\n }\n\n let activeCategory = getCategoryForTab(activeTab);\n\n const openMigrationModal = () => {\n const modalContent = document.createElement('div');\n modalContent.innerHTML = `\n <form id=\"convert-cloud-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Cloud Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a local/offline account. All data will be migrated to the secure cloud database.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"migrate-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"Company Name\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"migrate-admin-name\" required placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Phone Number</label>\n <input class=\"form-input\" id=\"migrate-admin-phone\" required placeholder=\"e.g. 0412 345 678\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Email Address (Username)</label>\n <input class=\"form-input\" type=\"email\" id=\"migrate-admin-email\" required placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Password</label>\n <input class=\"form-input\" type=\"password\" id=\"migrate-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"migration-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"migration-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-migrate-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-migrate-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" id=\"submit-icon\" style=\"font-size:18px;\">cloud_done</span>\n <span id=\"submit-text\">Register & Start Migration</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: 'Register & Migrate to Cloud',\n content: modalContent,\n size: 'modal-md'\n });\n\n modalContent.querySelector('#btn-migrate-cancel').addEventListener('click', close);\n\n const form = modalContent.querySelector('#convert-cloud-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n\n const errorEl = modalContent.querySelector('#migration-error');\n const errorTextEl = modalContent.querySelector('#migration-error-text');\n const submitBtn = modalContent.querySelector('#btn-migrate-submit');\n const cancelBtn = modalContent.querySelector('#btn-migrate-cancel');\n const submitText = modalContent.querySelector('#submit-text');\n const submitIcon = modalContent.querySelector('#submit-icon');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n submitText.textContent = 'Migrating to Cloud...';\n submitIcon.className = 'material-icons-outlined spinner';\n submitIcon.textContent = 'sync';\n submitIcon.style.animation = 'spin 1s linear infinite';\n\n const companyName = modalContent.querySelector('#migrate-company-name').value.trim();\n const adminName = modalContent.querySelector('#migrate-admin-name').value.trim();\n const adminPhone = modalContent.querySelector('#migrate-admin-phone').value.trim();\n const email = modalContent.querySelector('#migrate-admin-email').value.trim();\n const password = modalContent.querySelector('#migrate-admin-password').value;\n\n try {\n const settings = store.getSettings();\n settings.name = companyName;\n await store.saveSettings(settings);\n\n const { supabase } = await import('../utils/supabase.js');\n\n const { data: authData, error: authErr } = await supabase.auth.signUp({\n email,\n password,\n options: {\n data: {\n name: adminName,\n phone: adminPhone\n }\n }\n });\n if (authErr) throw authErr;\n\n if (!authData.user) {\n throw new Error('Verification required or signup was blocked. Check your email inbox.');\n }\n\n const { data: companyId, error: rpcError } = await supabase.rpc('create_company_and_admin', {\n user_id: authData.user.id,\n company_name: companyName,\n admin_name: adminName,\n admin_phone: adminPhone\n });\n if (rpcError) throw rpcError;\n\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n await store.migrateLocalToCloud(companyId, authData.user.id);\n\n if (activeAccountId) {\n const localAccountsKey = 'relay_accounts';\n let localAccounts = [];\n try {\n const stored = localStorage.getItem(localAccountsKey);\n if (stored) {\n localAccounts = JSON.parse(stored);\n }\n } catch (e) {\n console.error('Error reading local accounts:', e);\n }\n localAccounts = localAccounts.filter(a => a.id !== activeAccountId);\n localStorage.setItem(localAccountsKey, JSON.stringify(localAccounts));\n\n store.deleteLocalAccountData(activeAccountId);\n }\n\n const { data: profile, error: profileErr } = await supabase\n .from('profiles')\n .select('*')\n .eq('id', authData.user.id)\n .single();\n if (profileErr) throw profileErr;\n\n const user = {\n id: profile.id,\n companyId: profile.company_id,\n name: profile.name,\n role: profile.role,\n userTypeName: 'Admin',\n userTypeId: `${profile.company_id}_ut_admin`,\n color: profile.color || '#FF5C00',\n theme: 'light'\n };\n\n localStorage.setItem('currentUser', JSON.stringify(user));\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Migration completed successfully! Redirecting...', 'success');\n close();\n\n setTimeout(() => {\n window.location.hash = '#/';\n window.location.reload();\n }, 1500);\n\n } catch (err) {\n console.error('Migration failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during migration.';\n errorEl.style.display = 'flex';\n\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n submitText.textContent = 'Register & Start Migration';\n submitIcon.className = 'material-icons-outlined';\n submitIcon.textContent = 'cloud_done';\n submitIcon.style.animation = '';\n }\n });\n };\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n const categories = [\n {\n id: 'general',\n label: 'General',\n icon: 'settings',\n tabs: [\n { id: 'company', label: 'Company Profile' },\n { id: 'portal', label: 'Customer Portal', disabled: isPortalDisabled, tooltip: 'Requires Cloud Account' },\n { id: 'portal_contractor', label: 'Contractor Portal', disabled: isPortalDisabled, tooltip: 'Requires Cloud Account' },\n { id: 'folder_sync', label: 'Folder Sync', disabled: isFolderSyncDisabled, tooltip: 'Requires Local Folder Storage' },\n { id: 'api_keys', label: 'Integrations & APIs' },\n { id: 'system', label: 'System Options' }\n ]\n },\n {\n id: 'workflow',\n label: 'Workflow',\n icon: 'account_tree',\n tabs: [\n { id: 'templates_forms', label: 'Templates & Forms' },\n { id: 'invoices_quotes', label: 'Quotes & Invoices' },\n // v1.3 #3 — dark until FLAGS.payments flips on for launch\n ...(FLAGS.payments ? [{ id: 'payments', label: 'Payments' }] : [])\n ]\n },\n {\n id: 'people',\n label: 'People',\n icon: 'groups',\n tabs: [\n { id: 'users', label: 'Users & Permissions', disabled: isUsersDisabled, tooltip: 'Requires Cloud Account or Local Server' },\n { id: 'suppliers', label: 'Suppliers Configuration' }\n ]\n },\n {\n id: 'resources',\n label: 'Resources',\n icon: 'widgets',\n tabs: [\n { id: 'materials', label: 'Materials & Kits' },\n { id: 'cost_centers', label: 'Cost Centers & Xero' },\n { id: 'tax', label: 'Tax & Labor Rates' }\n ]\n }\n ];\n\n function render() {\n const categoryTabsHtml = categories.map(cat => {\n const isActive = activeCategory === cat.id;\n return `\n <button class=\"settings-category-btn ${isActive ? 'active' : ''}\" data-category=\"${cat.id}\">\n <span class=\"material-icons-outlined\">${cat.icon}</span>\n <span>${cat.label}</span>\n </button>\n `;\n }).join('');\n\n const activeCatData = categories.find(cat => cat.id === activeCategory) || categories[0];\n const subTabsHtml = activeCatData.tabs.map(tab => {\n const isActive = activeTab === tab.id;\n const isDisabled = tab.disabled;\n const tooltipAttr = isDisabled ? `data-tooltip=\"${tab.tooltip}\" data-tooltip-pos=\"top\"` : '';\n return `\n <button class=\"settings-tab-btn ${isActive ? 'active' : ''} ${isDisabled ? 'disabled-local' : ''}\" data-tab=\"${tab.id}\" ${tooltipAttr}>\n ${tab.label}\n </button>\n `;\n }).join('');\n\n container.innerHTML = `\n <style>\n .settings-nav-categories {\n display: flex;\n gap: 8px;\n border-bottom: 2px solid var(--border-color);\n padding-bottom: 12px;\n margin-bottom: 20px;\n }\n .settings-category-btn {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 10px 20px;\n font-size: 15px;\n font-weight: 600;\n color: var(--text-secondary);\n background: transparent;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n .settings-category-btn .material-icons-outlined {\n font-size: 20px;\n transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n .settings-category-btn:hover {\n color: var(--text-primary);\n background: var(--bg-color);\n }\n .settings-category-btn:hover .material-icons-outlined {\n transform: scale(1.08);\n }\n .settings-category-btn.active {\n color: white;\n background: var(--color-primary);\n box-shadow: 0 4px 14px rgba(255, 92, 0, 0.25);\n transform: translateY(-1px);\n }\n \n .settings-nav-tabs {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-bottom: 24px;\n background: var(--bg-color);\n padding: 6px;\n border-radius: 10px;\n border: 1px solid var(--border-color);\n animation: subtabs-slide-in 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);\n }\n @keyframes subtabs-slide-in {\n from {\n opacity: 0;\n transform: translateX(-10px);\n }\n to {\n opacity: 1;\n transform: translateX(0);\n }\n }\n .settings-tab-btn {\n padding: 8px 16px;\n font-size: var(--font-size-sm);\n font-weight: 500;\n color: var(--text-secondary);\n background: transparent;\n border: none;\n border-radius: 20px;\n cursor: pointer;\n transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n .settings-tab-btn:hover:not(.disabled-local) {\n color: var(--text-primary);\n background: rgba(128, 128, 128, 0.08);\n }\n .settings-tab-btn.active {\n color: var(--color-primary);\n background: var(--content-bg);\n font-weight: 600;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);\n transform: scale(1.02);\n }\n \n #settings-content {\n animation: settings-fade-in 0.28s cubic-bezier(0.4, 0, 0.2, 1);\n }\n @keyframes settings-fade-in {\n from {\n opacity: 0;\n transform: translateY(6px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n }\n </style>\n\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"settings-nav-categories\">\n ${categoryTabsHtml}\n </div>\n\n <div class=\"settings-nav-tabs\">\n ${subTabsHtml}\n </div>\n\n <div id=\"settings-content\" style=\"padding-top:var(--space-sm)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.settings-category-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n const catId = btn.dataset.category;\n if (activeCategory === catId) return;\n\n activeCategory = catId;\n const catData = categories.find(c => c.id === catId);\n const firstActiveTab = catData.tabs.find(t => !t.disabled) || catData.tabs[0];\n activeTab = firstActiveTab.id;\n\n render();\n });\n });\n\n container.querySelectorAll('.settings-tab-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n if (btn.classList.contains('disabled-local')) return;\n activeTab = btn.dataset.tab;\n render();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'suppliers') {\n renderSuppliersSettings(tc);\n return;\n }\n\n if (activeTab === 'folder_sync') {\n renderFolderSyncTab(tc);\n return;\n }\n\n if (activeTab === 'api_keys') {\n renderApiKeysTab(tc);\n return;\n }\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'payments') {\n renderPaymentsTab(tc);\n return;\n }\n\n if (activeTab === 'cost_centers') {\n renderCostCentersTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n\n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Standard Logo (Large) using Canvas to ~600x300 max bounding box\n compressImage(re.target.result, 600, 300).then((compressed) => {\n pendingLogo = compressed;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Small/Shrunk Logo using Canvas to ~200x100 max bounding box\n compressImage(re.target.result, 200, 100).then((compressed) => {\n pendingLogoSmall = compressed;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n\n\n tc.querySelector('#btn-save-company').addEventListener('click', async () => {\n const saveBtn = tc.querySelector('#btn-save-company');\n const originalHtml = saveBtn.innerHTML;\n saveBtn.disabled = true;\n saveBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving Changes...';\n\n try {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n await store.saveSettings(settings);\n showToast('Company information saved successfully to database', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n render();\n } catch (err) {\n console.error('Error saving company profile settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n saveBtn.disabled = false;\n saveBtn.innerHTML = originalHtml;\n }\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n const pendingResets = store.getAll('passwordResetRequests') || [];\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div style=\"background:rgba(59, 130, 246, 0.1); border-left:4px solid #3b82f6; padding:12px 16px; margin-bottom:var(--space-md); border-radius:4px; font-size:13px; color:#f8fafc; display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:#3b82f6; font-size:18px;\">info</span>\n <span>Your company login code is <strong style=\"color:#60a5fa\">${companySlug}</strong>. Technicians log in using <strong style=\"color:#60a5fa\">username@${companySlug}</strong>.</span>\n </span>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Username</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n ${ut?.id !== 'ut_admin' ? `\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n ` : ''}\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\">\n <h4 style=\"margin:0\">Pending Password Reset Requests</h4>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Username/Email</th>\n <th>Requested On</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:24px\">No pending reset requests</td></tr>' : ''}\n ${pendingResets.filter(r => r.status === 'pending').map(r => {\n const tech = techs.find(t => t.id === r.technician_id) || {};\n const requestedAt = r.requested_at ? new Date(r.requested_at).toLocaleString() : 'Unknown';\n return `\n <tr>\n <td class=\"font-medium\">${tech.name || 'Unknown'}</td>\n <td class=\"text-secondary\">${r.employee_id || tech.username || ''}</td>\n <td class=\"text-tertiary\">${requestedAt}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-approve-reset\" data-id=\"${r.id}\" data-tech-id=\"${tech.id}\">Approve &amp; Reset</button>\n <button class=\"btn btn-sm btn-ghost text-danger btn-deny-reset\" data-id=\"${r.id}\">Deny</button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n ${isLocalMode ? '' : `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `}\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => {\n openUserModal();\n });\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-approve-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const techId = e.currentTarget.dataset.techId;\n const t = store.getById('technicians', techId);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <p>Approve password reset request for <strong>${t.name}</strong>? Please enter their new password below:</p>\n <div class=\"form-group\" style=\"margin-top:12px\">\n <label class=\"form-label\">New Password</label>\n <input type=\"password\" id=\"admin-reset-pwd-input\" class=\"form-input\" placeholder=\"Min. 6 characters\" minlength=\"6\" autofocus />\n </div>\n `;\n showModal({\n title: 'Approve Password Reset',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reset Password', className: 'btn-primary', onClick: c => {\n const pwdInput = document.getElementById('admin-reset-pwd-input');\n const newPwd = pwdInput ? pwdInput.value : '';\n if (!newPwd || newPwd.length < 6) {\n showToast('Password must be at least 6 characters.', 'error');\n return;\n }\n \n // Update technician password\n store.update('technicians', techId, { password: newPwd });\n \n // Approve the request\n store.update('passwordResetRequests', id, { status: 'approved', updated_at: new Date().toISOString() });\n \n showToast(`Password updated for ${t.name}`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-deny-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n store.update('passwordResetRequests', id, { status: 'denied', updated_at: new Date().toISOString() });\n showToast('Password reset request denied', 'info');\n renderContent();\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n let currentJobTypes = settings.jobTypes ? [...settings.jobTypes] : ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'];\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg)\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax Rates</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\" style=\"display:flex; flex-direction:column; gap:8px\">\n <label class=\"form-label\" style=\"display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:0\">\n <input type=\"checkbox\" id=\"tax-enabled\" style=\"width:16px; height:16px; margin:0\" ${settings.taxEnabled !== false ? 'checked' : ''} />\n Enable GST / Sales Tax\n </label>\n <div style=\"display:${settings.taxEnabled !== false ? 'flex' : 'none'}; align-items:center; gap:8px; margin-top:4px\" id=\"tax-rate-container\">\n <input class=\"form-input\" id=\"tax-rate\" type=\"number\" value=\"${settings.taxRate !== undefined ? settings.taxRate : 10}\" style=\"width:100px\" min=\"0\" max=\"100\" step=\"0.1\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" id=\"job-types-card\">\n <div class=\"card-header\"><h4>Job Types</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:12px\">Define the types of jobs your team performs (e.g. Electrical, Plumbing). These appear in job forms and rate mapping.</p>\n <div id=\"job-types-list\" style=\"display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px;\">\n ${currentJobTypes.map(type => `\n <span class=\"tag-pill tag-pill-active\" style=\"display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:100px; font-weight:500; font-size:13px; background:var(--color-primary-light); color:var(--color-primary-dark)\">\n <span>${escapeHTML(type)}</span>\n <span class=\"material-icons-outlined delete-job-type-btn\" data-type=\"${escapeHTML(type)}\" style=\"font-size:16px; cursor:pointer; opacity:0.7; transition:opacity 0.2s\">close</span>\n </span>\n `).join('')}\n </div>\n <div style=\"display:flex; gap:8px;\">\n <input class=\"form-input\" id=\"new-job-type-input\" type=\"text\" placeholder=\"e.g. Renovation\" style=\"flex:1\" />\n <button class=\"btn btn-secondary\" id=\"add-job-type-btn\" style=\"display:flex; align-items:center; gap:4px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${escapeHTML(rate.name)}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${escapeHTML(rate.description || '')}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => {\n const active = applicable.includes(day);\n return `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${active ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'};background:${active ? 'var(--color-primary-light)' : 'transparent'};color:${active ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `}).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // Toggle tax rate container visibility\n tc.querySelector('#tax-enabled')?.addEventListener('change', (e) => {\n const container = tc.querySelector('#tax-rate-container');\n if (container) {\n container.style.display = e.target.checked ? 'flex' : 'none';\n }\n });\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Job Types Management Event Listeners ----\n const addJobTypeBtn = tc.querySelector('#add-job-type-btn');\n const newJobTypeInput = tc.querySelector('#new-job-type-input');\n const jobTypesList = tc.querySelector('#job-types-list');\n\n function updateJobTypesUI() {\n jobTypesList.innerHTML = currentJobTypes.map(type => `\n <span class=\"tag-pill tag-pill-active\" style=\"display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:100px; font-weight:500; font-size:13px; background:var(--color-primary-light); color:var(--color-primary-dark)\">\n <span>${escapeHTML(type)}</span>\n <span class=\"material-icons-outlined delete-job-type-btn\" data-type=\"${escapeHTML(type)}\" style=\"font-size:16px; cursor:pointer; opacity:0.7; transition:opacity 0.2s\">close</span>\n </span>\n `).join('');\n }\n\n addJobTypeBtn?.addEventListener('click', () => {\n const val = newJobTypeInput.value.trim();\n if (!val) {\n showToast('Please enter a job type name', 'warning');\n return;\n }\n if (currentJobTypes.includes(val)) {\n showToast('This job type already exists', 'warning');\n return;\n }\n currentJobTypes.push(val);\n newJobTypeInput.value = '';\n updateJobTypesUI();\n });\n\n tc.querySelector('#job-types-card')?.addEventListener('click', (e) => {\n const btn = e.target.closest('.delete-job-type-btn');\n if (btn) {\n const typeToDelete = btn.dataset.type;\n currentJobTypes = currentJobTypes.filter(t => t !== typeToDelete);\n updateJobTypesUI();\n }\n });\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#save-tax-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const taxEnabled = tc.querySelector('#tax-enabled').checked;\n const taxRate = parseFloat(tc.querySelector('#tax-rate').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.taxEnabled = taxEnabled;\n settings.taxRate = taxRate;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n settings.jobTypes = currentJobTypes;\n\n await store.saveSettings(settings);\n showToast('Financial and Rate settings saved successfully', 'success');\n renderContent();\n } catch (err) {\n console.error('Error saving tax and rate settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-portal-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n await store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving customer portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'portal_contractor') {\n const s = store.getSettings();\n const portalEnabled = s.enableContractorPortal !== false;\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Contractor Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"contractor-portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('contractor-portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Contractor Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any subcontractor attempting to load their portal token will see an access deactivated notice.</span>\n </div>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-contractor-settings\" data-tooltip=\"Save subcontractor portal access rules\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Contractor Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-contractor-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-contractor-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableContractorPortal = tc.querySelector('#contractor-portal-enable').checked;\n\n await store.saveSettings(settings);\n showToast('Contractor portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving contractor portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'system') {\n\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n const currentTheme = (currentUser && currentUser.id) ? (currentUser.theme || localStorage.getItem(`simpro_theme_${currentUser.id}`) || 'light') : 'light';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Left Column -->\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg)\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n </p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n ${currentUser.role === 'admin' ? `\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed a complete demonstration dataset\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed Demonstration Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n <button class=\"btn btn-danger\" id=\"btn-delete-company\" data-tooltip=\"Permanently delete the entire company profile and all data\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; background:var(--color-danger); border-color:var(--color-danger); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span> Delete Company Profile\n </button>\n\n ${isLocalMode ? `\n <div style=\"margin-top:16px; padding-top:12px; border-top:1px dashed rgba(220, 38, 38, 0.2)\">\n <h6 style=\"color:var(--color-warning); margin-bottom:8px; font-weight:600; display:flex; align-items:center; gap:4px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">warning</span> Irreversible Profile Conversions\n </h6>\n <p style=\"font-size:11px; color:var(--text-secondary); margin-bottom:12px; line-height:1.3;\">\n Convert your deployment type. These transitions are permanent and one-way.\n </p>\n ${localDeploymentType === 'single_user' ? `\n <button class=\"btn\" id=\"btn-convert-multiuser\" style=\"width:100%; justify-content:center; margin-bottom:12px; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">lan</span> Convert to Multi-User Local Network Sync\n </button>\n ` : ''}\n <button class=\"btn\" id=\"btn-convert-cloud-action\" style=\"width:100%; justify-content:center; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">cloud_upload</span> Convert to Cloud Sync\n </button>\n </div>\n ` : ''}\n </div>\n ` : '<div style=\"font-size:12.5px; color:var(--text-tertiary)\">Administrative actions are restricted to company administrators.</div>'}\n </div>\n </div>\n </div>\n\n <!-- Local Data Backup -->\n ${!isLocalMode && currentUser.role === 'admin' ? `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary)\">backup</span>\n <h4 style=\"margin:0;\">Local Data Backup</h4>\n </div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:12px;\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm); line-height:1.4; margin:0;\">\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n </p>\n <div id=\"backup-status-container\"></div>\n </div>\n </div>\n ` : ''}\n </div>\n\n <!-- Right Column -->\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">System Color Theme</label>\n <select class=\"form-select\" id=\"system-theme-select\" style=\"width:100%\">\n ${Object.entries(THEMES).map(([key, val]) => `\n <option value=\"${key}\" ${currentTheme === key ? 'selected' : ''}>${val.name}</option>\n `).join('')}\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Select a color profile and visual background effect for your user interface.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', async (e) => {\n const select = e.target;\n select.disabled = true;\n try {\n const settings = store.getSettings();\n settings.tooltipPreference = select.value;\n await store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved successfully', 'success');\n } catch (err) {\n console.error('Error saving tooltip preference:', err);\n showToast('Failed to save preferences: ' + (err.message || err), 'error');\n } finally {\n select.disabled = false;\n }\n });\n\n tc.querySelector('#system-theme-select')?.addEventListener('change', (e) => {\n applyTheme(e.target.value, true);\n showToast('System theme updated successfully', 'success');\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a complete demonstration dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load a complete, realistic demonstration dataset representing an active trade business (5 customers, 5 quotes/jobs per customer, 5 assets per customer, 5 materials, 5 users/timesheets, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for testing and walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed Demonstration Data\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Demo Data\",\n className: \"btn-primary\",\n onClick: async (close) => {\n close();\n showToast('Seeding demonstration data...', 'info');\n await seedMinimalData();\n showToast('Demonstration data seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-delete-company')?.addEventListener('click', () => {\n // Modal Warning 1 of 2\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">warning</span>\n <span>You are about to permanently delete the entire company profile for <strong>${escapeHTML(store.getSettings().name || '')}</strong> and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).</span>\n </div>\n <p style=\"font-size:12.5px; color:var(--text-secondary); margin-bottom:12px\">\n This action is final, irreversible, and cannot be undone under any circumstances.\n </p>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Do you want to proceed to password confirmation?</p>\n `;\n\n showModal({\n title: \"Delete Company Profile - Step 1 of 2\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (closeModal) => closeModal()\n },\n {\n label: \"Proceed to Confirm\",\n className: \"btn-danger\",\n onClick: (closeModal) => {\n closeModal();\n \n // Modal Warning 2 of 2\n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <form id=\"delete-company-confirm-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <p style=\"margin-bottom:8px; font-weight:600; color:var(--color-danger)\">FINAL CONFIRMATION (2 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">gavel</span>\n <span>To authorize the permanent destruction of this company, you must enter the Administrator password.</span>\n </div>\n\n <div class=\"form-group\" style=\"margin-top:12px;\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Password</label>\n <input class=\"form-input\" type=\"password\" id=\"delete-confirm-password\" required placeholder=\"Enter admin password to proceed\" />\n </div>\n\n <div id=\"delete-confirm-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"delete-confirm-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-delete-abort\">Abort Deletion</button>\n <button type=\"submit\" class=\"btn btn-danger\" id=\"btn-delete-confirm-submit\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span>\n <span>Permanently Delete Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close: close2 } = showModal({\n title: \"⚠️ Confirm Company Deletion - Step 2 of 2\",\n content: content2,\n size: \"modal-md\"\n });\n\n content2.querySelector('#btn-delete-abort').addEventListener('click', close2);\n\n const form2 = content2.querySelector('#delete-company-confirm-form');\n form2.addEventListener('submit', async (ev) => {\n ev.preventDefault();\n\n const errorEl = content2.querySelector('#delete-confirm-error');\n const errorTextEl = content2.querySelector('#delete-confirm-error-text');\n const submitBtn = content2.querySelector('#btn-delete-confirm-submit');\n const abortBtn = content2.querySelector('#btn-delete-abort');\n const passwordInput = content2.querySelector('#delete-confirm-password').value;\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n abortBtn.disabled = true;\n const origSubmitText = submitBtn.innerHTML;\n submitBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Deleting...';\n\n try {\n let passwordVerified = false;\n\n if (isLocalMode) {\n if (localDeploymentType === 'single_user') {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acct = storedAccounts.find(a => a.id === activeAccountId);\n if (acct && acct.hasPassword) {\n const hashedInput = await hashPassword(passwordInput);\n if (hashedInput === acct.passwordHash) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n // No password set\n passwordVerified = true;\n }\n } else {\n // local_multiuser\n const techs = store.getAll('technicians') || [];\n const currentTech = techs.find(t => t.id === currentUser.id);\n const expectedPassword = currentTech ? currentTech.password : '123456';\n if (passwordInput === expectedPassword) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n }\n } else {\n // Cloud mode\n const { supabase } = await import('../utils/supabase.js');\n const { data: { user } } = await supabase.auth.getUser();\n if (user && user.email) {\n const { error } = await supabase.auth.signInWithPassword({\n email: user.email,\n password: passwordInput\n });\n if (!error) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n throw new Error('Could not retrieve logged-in cloud user credentials.');\n }\n }\n\n if (passwordVerified) {\n // 1. If cloud mode, delete the company row in Supabase\n if (!isLocalMode) {\n const { supabase } = await import('../utils/supabase.js');\n const { error: deleteErr } = await supabase.from('companies').delete().eq('id', store.companyId);\n if (deleteErr) {\n throw new Error('Failed to delete cloud company: ' + deleteErr.message);\n }\n }\n\n // 2. Trigger store.clearAll()\n store.clearAll();\n\n // 3. Clear local account launcher keys if local mode\n if (isLocalMode) {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const updatedAccounts = storedAccounts.filter(a => a.id !== activeAccountId);\n await storageSet('relay_accounts', updatedAccounts);\n }\n }\n\n // 4. Remove session user & active account\n localStorage.removeItem('currentUser');\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Company profile deleted successfully.', 'success');\n close2();\n\n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n } catch (err) {\n console.error('Company deletion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during verification.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n abortBtn.disabled = false;\n submitBtn.innerHTML = origSubmitText;\n }\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-convert-multiuser')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n\n const techs = store.getAll('technicians') || [];\n const companyId = store.companyId || '';\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const existingAdmin = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n const currentTechName = existingAdmin ? existingAdmin.name : (currentUser.name && currentUser.name !== 'Local Admin' ? currentUser.name : '');\n const currentTechUsernameOrEmail = existingAdmin ? (existingAdmin.email || existingAdmin.username || '') : '';\n\n content.innerHTML = `\n <form id=\"convert-multiuser-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Multi-User Local Network Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a single-user Local Admin profile.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"multiuser-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"e.g. Apex Power Services\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"multiuser-admin-name\" required value=\"${escapeHTML(currentTechName)}\" placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Email Address (Required)</label>\n <input class=\"form-input\" type=\"email\" id=\"multiuser-admin-email\" required value=\"${escapeHTML(currentTechUsernameOrEmail)}\" placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Admin Password</label>\n <input class=\"form-input\" type=\"password\" id=\"multiuser-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"multiuser-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"multiuser-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-multiuser-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-multiuser-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">lan</span>\n <span>Convert Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: \"Convert to Multi-User Local Sync\",\n content: content,\n size: \"modal-md\"\n });\n\n content.querySelector('#btn-multiuser-cancel').addEventListener('click', close);\n\n const form = content.querySelector('#convert-multiuser-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n const errorEl = content.querySelector('#multiuser-error');\n const errorTextEl = content.querySelector('#multiuser-error-text');\n const submitBtn = content.querySelector('#btn-multiuser-submit');\n const cancelBtn = content.querySelector('#btn-multiuser-cancel');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n\n const businessName = content.querySelector('#multiuser-company-name').value.trim();\n const adminName = content.querySelector('#multiuser-admin-name').value.trim();\n const adminEmail = content.querySelector('#multiuser-admin-email').value.trim();\n const adminPassword = content.querySelector('#multiuser-admin-password').value;\n\n try {\n if (!businessName || !adminName || !adminEmail || !adminPassword) {\n throw new Error('All fields are required.');\n }\n if (adminPassword.length < 6) {\n throw new Error('Password must be at least 6 characters.');\n }\n\n // 1. Update settings\n const settings = store.getSettings();\n settings.name = businessName;\n settings.localDeploymentType = 'multi_user';\n await store.saveSettings(settings);\n\n // 2. Update launcher accounts businessName\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acctIndex = storedAccounts.findIndex(a => a.id === activeAccountId);\n if (acctIndex !== -1) {\n storedAccounts[acctIndex].businessName = businessName;\n await storageSet('relay_accounts', storedAccounts);\n }\n }\n\n // 3. Create or update admin user in technicians\n const companyId = store.companyId;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const techs = store.getAll('technicians') || [];\n let adminTech = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n if (!adminTech) {\n adminTech = {\n id: `${companyId}_tech_admin_created`,\n role: 'Admin',\n color: '#FF5C00',\n userTypeId: adminTypeId,\n payRate: 100.00,\n phone: ''\n };\n techs.push(adminTech);\n }\n\n adminTech.name = adminName;\n adminTech.username = adminEmail.split('@')[0];\n adminTech.email = adminEmail;\n adminTech.password = adminPassword;\n\n store.save('technicians', techs);\n\n // 4. Update currentUser session in localStorage\n const localUser = {\n id: adminTech.id,\n companyId: companyId,\n name: adminTech.name,\n role: 'admin',\n userTypeName: 'Admin',\n userTypeId: adminTech.userTypeId,\n color: adminTech.color || '#FF5C00',\n theme: 'light'\n };\n localStorage.setItem('currentUser', JSON.stringify(localUser));\n\n showToast('Converted to Multi-User Local Network Sync. Reloading...', 'success');\n close();\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n setTimeout(() => window.location.reload(), 1200);\n } catch (err) {\n console.error('Conversion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during conversion.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n }\n });\n });\n\n tc.querySelector('#btn-convert-cloud-action')?.addEventListener('click', () => {\n openMigrationModal();\n });\n\n const renderBackupStatus = () => {\n const bsc = tc.querySelector('#backup-status-container');\n if (!bsc) return;\n\n const hasHandle = !!store.backupDirHandle;\n const isPermissionGranted = store.backupDirPermissionGranted;\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const formattedLastBackup = lastBackup ? new Date(lastBackup).toLocaleString() : 'Never';\n const isSupported = typeof window !== 'undefined' && window.showDirectoryPicker;\n\n if (!isSupported) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-danger); line-height:1.4;\">\n <strong style=\"display:block; margin-bottom:4px;\">Browser Local Folder Access Unsupported</strong>\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n </div>\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:12px; border-radius:6px; margin-bottom:12px; color:var(--text-secondary); display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary);\">folder_off</span>\n <div style=\"font-size:12.5px;\">No backup folder configured.</div>\n </div>\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">folder</span> Choose Backup Folder...\n </button>\n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-warning); line-height:1.4; margin-bottom:12px;\">\n <strong>Permission Required</strong><br/>\n Access permission to <strong>${escapeHTML(store.backupDirHandle.name)}</strong> has expired. Please re-authorize.\n </div>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-warning\" id=\"btn-backup-auth\" style=\"flex:1; justify-content:center;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize & Backup\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n `;\n } else {\n bsc.innerHTML = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-success); line-height:1.4; margin-bottom:12px;\">\n <strong>Backup Configured</strong><br/>\n Folder: <strong>${escapeHTML(store.backupDirHandle.name)}</strong><br/>\n Last Backup: <strong>${formattedLastBackup}</strong>\n </div>\n <div style=\"display:flex; flex-direction:column; gap:8px;\">\n <button class=\"btn btn-primary\" id=\"btn-backup-now\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">backup</span> Backup Now\n </button>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"flex:1; justify-content:center; border:1px solid var(--border-color);\">\n <span class=\"material-icons-outlined\">folder</span> Change Folder...\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n </div>\n `;\n }\n\n // Attach event listeners for backup buttons inside container\n bsc.querySelector('#btn-backup-pick')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setBackupDirectory(handle);\n showToast('Backup directory selected successfully.', 'success');\n showToast('Running initial backup...', 'info');\n await store.backupToFolder(handle);\n showToast('Backup completed successfully!', 'success');\n renderBackupStatus();\n } catch (err) {\n console.error('Failed to select backup directory:', err);\n if (err.name !== 'AbortError') {\n showToast('Failed to configure backup: ' + err.message, 'error');\n }\n }\n });\n\n bsc.querySelector('#btn-backup-auth')?.addEventListener('click', async () => {\n const granted = await store.verifyBackupDirPermission(true);\n if (granted) {\n showToast('Permission re-authorized. Backing up...', 'info');\n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n showToast('Backup failed: ' + err.message, 'error');\n }\n } else {\n showToast('Failed to acquire write permission.', 'error');\n }\n renderBackupStatus();\n });\n\n bsc.querySelector('#btn-backup-now')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Backing up...';\n \n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Backup failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n renderBackupStatus();\n }\n });\n\n bsc.querySelector('#btn-backup-disconnect')?.addEventListener('click', async () => {\n await store.setBackupDirectory(null);\n showToast('Backup folder disconnected.', 'info');\n renderBackupStatus();\n });\n };\n\n if (!isLocalMode && currentUser.role === 'admin') {\n renderBackupStatus();\n }\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', username: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Username</label>\n <input class=\"form-input\" id=\"u-username\" value=\"${t.username || (t.email ? t.email.split('@')[0] : '')}\" ${editId ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''} placeholder=\"e.g. joshua\" />\n ${!editId ? `\n <div style=\"font-size: 11px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500;\">\n Company login code is: <strong style=\"color: var(--color-primary)\">${companySlug}</strong>. User will log in with <strong style=\"color: var(--color-primary)\">username@${companySlug}</strong>\n </div>\n ` : ''}\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">${editId ? 'Reset Password (leave blank to keep current)' : 'Temporary Password (assigned for worker login)'}</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"${editId ? '••••••••' : 'Min. 6 characters'}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\" ${(t.userTypeId === 'ut_admin' || (t.userTypeId && t.userTypeId.endsWith('_ut_admin'))) ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''}>\n <option value=\"\">-- Select --</option>\n ${userTypes\n .filter(ut => (!ut.id.endsWith('_ut_admin') && ut.id !== 'ut_admin') || t.userTypeId === ut.id)\n .map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value.trim();\n const username = document.getElementById('u-username').value.trim();\n const role = document.getElementById('u-role').value.trim();\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = document.getElementById('u-password')?.value || '';\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!username) { showToast('Username required', 'error'); return; }\n if (username.includes('@')) { showToast('Username cannot contain @ symbol', 'error'); return; }\n \n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (password && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n const updates = { name, username, role, userTypeId, color, payRate };\n if (password) {\n updates.password = password;\n }\n\n if (editId) {\n await store.update('technicians', editId, updates);\n } else {\n await store.create('technicians', updates);\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = async () => {\n const btn = tc.querySelector('#btn-save-materials');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n await store.saveSettings(updatedSettings);\n showToast('Material settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving material settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderPaymentsTab(tc) {\n const settings = store.getSettings() || {};\n const pay = settings.payments || {};\n const isCloud = !!(store.companyId && !String(store.companyId).startsWith('acct_'));\n\n if (!isCloud) {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:760px\">\n <div class=\"card-header\"><h4>Online Payments (Stripe)</h4></div>\n <div class=\"card-body\">\n <p style=\"color:var(--text-secondary);\">Online card payments are a cloud feature. Upgrade to a cloud account to let customers pay invoices online by card.</p>\n </div>\n </div>`;\n return;\n }\n\n const enabledFor = pay.enabledFor || {};\n const currencies = ['AUD', 'USD', 'NZD', 'GBP', 'EUR'];\n const cur = (pay.currency || 'AUD').toUpperCase();\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:820px\">\n <div class=\"card-header\"><h4>Online Payments (Stripe)</h4></div>\n <div class=\"card-body\">\n <p style=\"color:var(--text-secondary);margin-top:0;\">\n Let customers pay invoices by card via a secure Stripe checkout link. Generate a link from any sent invoice; it's marked Paid automatically once payment clears.\n </p>\n\n <div style=\"background:var(--content-bg);border:1px solid var(--border-color);border-radius:8px;padding:14px 16px;margin:14px 0;\">\n <div style=\"font-weight:600;font-size:13px;margin-bottom:8px;\">One-time setup</div>\n <ol style=\"margin:0;padding-left:18px;font-size:12px;color:var(--text-secondary);line-height:1.7;\">\n <li>Create a <strong>Stripe</strong> account and copy your <strong>Secret key</strong>.</li>\n <li>In Supabase → Edge Functions → Secrets, add <code>STRIPE_SECRET_KEY</code> and <code>STRIPE_WEBHOOK_SECRET</code>.</li>\n <li>Deploy the <code>relay-create-payment</code> and <code>relay-stripe-webhook</code> functions.</li>\n <li>In Stripe → Developers → Webhooks, add an endpoint pointing at <code>relay-stripe-webhook</code> and subscribe it to <code>checkout.session.completed</code>.</li>\n </ol>\n </div>\n\n <div class=\"form-group\" style=\"display:flex;align-items:center;gap:10px;\">\n <label class=\"switch\" style=\"margin:0;\">\n <input type=\"checkbox\" id=\"pay-connected\" ${pay.connected ? 'checked' : ''} />\n </label>\n <div>\n <div style=\"font-weight:600;font-size:13px;\">Payments configured &amp; live</div>\n <div style=\"font-size:11px;color:var(--text-tertiary);\">Tick once the setup above is done. Controls whether the Pay Link button appears on invoices.</div>\n </div>\n </div>\n\n <div class=\"form-group\" style=\"max-width:220px;\">\n <label class=\"form-label\">Currency</label>\n <select class=\"form-input\" id=\"pay-currency\">\n ${currencies.map(c => `<option value=\"${c}\" ${c === cur ? 'selected' : ''}>${c}</option>`).join('')}\n </select>\n </div>\n\n <div class=\"form-group\" style=\"display:flex;align-items:center;gap:10px;\">\n <input type=\"checkbox\" id=\"pay-enable-invoice\" style=\"width:16px;height:16px;\" ${enabledFor.invoice !== false ? 'checked' : ''} />\n <label for=\"pay-enable-invoice\" style=\"margin:0;font-size:13px;\">Show a \"Pay Link\" action on sent invoices</label>\n </div>\n\n <div style=\"margin-top:16px;\">\n <button class=\"btn btn-primary\" id=\"pay-save\"><span class=\"material-icons-outlined\">save</span> Save Payment Settings</button>\n </div>\n </div>\n </div>`;\n\n tc.querySelector('#pay-save')?.addEventListener('click', async () => {\n try {\n const s = store.getSettings() || {};\n s.payments = {\n ...(s.payments || {}),\n connected: tc.querySelector('#pay-connected').checked,\n currency: tc.querySelector('#pay-currency').value,\n enabledFor: {\n ...((s.payments || {}).enabledFor || {}),\n invoice: tc.querySelector('#pay-enable-invoice').checked,\n },\n };\n await store.saveSettings(s);\n showToast('Payment settings saved', 'success');\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n } catch (err) {\n console.error('Error saving payment settings:', err);\n showToast('Could not save payment settings', 'error');\n }\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-00023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-00129',\n originalQuoteNumber: 'Q-00412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#FF5C00', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' },\n ballet: { name: 'Ballet Pointe', preset: 'ballet', accentColor: '#e58799', headerBg: '#5c2c36', accentTint: '#fff5f6', fontFamily: 'serif' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-company-name\" style=\"width:16px; height:16px\" ${dt.hideCompanyName ? 'checked' : ''} />\n <label for=\"theme-hide-company-name\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide company name text (use logo only)</label>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Variation</label>\n <select class=\"form-select\" id=\"theme-logo-source\" style=\"width:100%\">\n <option value=\"large\" ${dt.logoSource === 'large' ? 'selected' : ''}>Standard Logo (Large)</option>\n <option value=\"small\" ${dt.logoSource === 'small' ? 'selected' : ''}>Small / Shrunk Logo (Small)</option>\n </select>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:8px 0\"></div>\n <div class=\"form-group ${isLocalMode ? 'disabled-local' : ''}\" ${isLocalMode ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''} style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-brand-logo\" style=\"width:16px; height:16px\" ${dt.hideBrandLogo ? 'checked' : ''} ${isLocalMode ? 'disabled' : ''} />\n <label for=\"theme-hide-brand-logo\" class=\"form-label\" style=\"margin:0; cursor:${isLocalMode ? 'not-allowed' : 'pointer'}\">Remove Relay-dispatch brand logo from document footers</label>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n <div class=\"form-row\" style=\"display:flex; gap:12px\">\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Invoice Prefix</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoicePrefix\" value=\"${escapeHTML(dt.invoicePrefix || 'INV-')}\" placeholder=\"INV-\" />\n </div>\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Invoice Starting Number</label>\n <input type=\"number\" class=\"form-input text-copy-input\" data-prop=\"invoiceStartingNumber\" value=\"${dt.invoiceStartingNumber !== undefined ? dt.invoiceStartingNumber : 1}\" min=\"1\" placeholder=\"1\" />\n </div>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n <div class=\"form-row\" style=\"display:flex; gap:12px\">\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Quote Prefix</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quotePrefix\" value=\"${escapeHTML(dt.quotePrefix || 'Q-')}\" placeholder=\"Q-\" />\n </div>\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Quote Starting Number</label>\n <input type=\"number\" class=\"form-input text-copy-input\" data-prop=\"quoteStartingNumber\" value=\"${dt.quoteStartingNumber !== undefined ? dt.quoteStartingNumber : 1}\" min=\"1\" placeholder=\"1\" />\n </div>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Hide Company Name toggle\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) {\n hideCompanyNameChk.addEventListener('change', () => {\n dt.hideCompanyName = hideCompanyNameChk.checked;\n updatePreview();\n });\n }\n\n // Logo Variation toggle\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) {\n logoSourceSelect.addEventListener('change', (e) => {\n dt.logoSource = e.target.value;\n updatePreview();\n });\n }\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Hide Brand Logo toggle\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) {\n hideBrandLogoChk.addEventListener('change', () => {\n dt.hideBrandLogo = hideBrandLogoChk.checked;\n updatePreview();\n });\n }\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-theme-save');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n await store.saveSettings(freshSettings);\n showToast('Document customization settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving theme settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n hideCompanyName: false,\n hideBrandLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) hideCompanyNameChk.checked = !!dt.hideCompanyName;\n\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) logoSourceSelect.value = dt.logoSource || 'large';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePrefixInp = tc.querySelector('[data-prop=\"invoicePrefix\"]');\n if (invoicePrefixInp) invoicePrefixInp.value = dt.invoicePrefix || '';\n\n const invoiceStartingNumberInp = tc.querySelector('[data-prop=\"invoiceStartingNumber\"]');\n if (invoiceStartingNumberInp) invoiceStartingNumberInp.value = dt.invoiceStartingNumber !== undefined ? dt.invoiceStartingNumber : 1;\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const quotePrefixInp = tc.querySelector('[data-prop=\"quotePrefix\"]');\n if (quotePrefixInp) quotePrefixInp.value = dt.quotePrefix || '';\n\n const quoteStartingNumberInp = tc.querySelector('[data-prop=\"quoteStartingNumber\"]');\n if (quoteStartingNumberInp) quoteStartingNumberInp.value = dt.quoteStartingNumber !== undefined ? dt.quoteStartingNumber : 1;\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) hideBrandLogoChk.checked = !!dt.hideBrandLogo;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n const logoUrl = dt.logoSource === 'small' ? (settings.logoSmall || settings.logo) : (settings.logo || settings.logoSmall);\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.src = logoUrl || '';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // Company Name visibility\n const companyNameEl = frameDoc.querySelector('.pdf-company-name');\n if (companyNameEl) {\n companyNameEl.style.display = dt.hideCompanyName ? 'none' : 'block';\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n\n const footerBrandingEl = frameDoc.querySelector('.pdf-footer-branding');\n if (footerBrandingEl) {\n const hideBrandLogo = isLocalMode ? false : !!dt.hideBrandLogo;\n footerBrandingEl.style.display = hideBrandLogo ? 'none' : 'flex';\n }\n }\n\n renderMarkup();\n }\n\n function renderFolderSyncTab(tc) {\n const isSupported = typeof window !== 'undefined' && (\n (window.indexedDB && window.showDirectoryPicker) ||\n (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem)\n );\n\n const isCapacitor = typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem;\n\n const activeAccountId = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n const isLocalCompany = !!activeAccountId;\n\n function render() {\n console.log('[DEBUG Settings] render: isLocalCompany =', isLocalCompany, 'store.dirHandle =', store.dirHandle, 'permissionGranted =', store.folderSyncPermissionGranted);\n const isEnabled = isLocalCompany ? true : store.folderSyncEnabled;\n const isPermissionGranted = store.folderSyncPermissionGranted;\n const hasHandle = !!store.dirHandle;\n\n let statusHtml = '';\n if (!isSupported) {\n statusHtml = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:16px; border-radius:4px; color:var(--color-danger); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:8px; font-weight:600; margin-bottom:4px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Browser Directory Access Unsupported</span>\n </div>\n <p style=\"font-size:var(--font-size-sm); margin:0; line-height:1.4;\">\n Your current browser does not support local folder access. To enable direct folder synchronization, please run this app in a Chromium-based browser (Chrome, Edge, Opera, or as a compiled native app). High-capacity IndexedDB storage remains active.\n </p>\n </div>\n `;\n } else if (!isEnabled) {\n statusHtml = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:16px; border-radius:6px; color:var(--text-secondary); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px; color:var(--text-tertiary);\">folder_off</span>\n <div>\n <div style=\"font-weight:600; color:var(--text-primary); margin-bottom:2px;\">Folder Synchronization Inactive</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4;\">All data is currently stored in your browser's private IndexedDB database sandbox.</p>\n </div>\n </div>\n `;\n } else if (isCapacitor) {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">cloud_done</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Capacitor Direct Folder Sync Active</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Data is synchronizing directly to the application's native <strong>Documents/RelayDispatchData</strong> directory on your iPad/device.\n </p>\n </div>\n </div>\n `;\n } else if (!hasHandle) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">warning</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">No Directory Selected</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">Please select a directory folder on your computer to begin syncing.</p>\n </div>\n </div>\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:12px; margin-bottom:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">lock</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Access Permission Suspended</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n The browser requires re-authorization to read/write files in <strong>${escapeHTML(store.dirHandle.name)}</strong>.\n </p>\n </div>\n </div>\n <button class=\"btn btn-warning\" id=\"btn-reauthorize-dir\" style=\"margin-left:44px;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize Access\n </button>\n </div>\n `;\n } else {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">check_circle</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Folder Sync Active & Synchronized</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Active Folder: <strong>${escapeHTML(store.dirHandle.name)}</strong>. All data edits are writing dynamically.\n </p>\n </div>\n </div>\n `;\n }\n\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <!-- Folder Configuration -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Direct Directory Sync</h4></div>\n <div class=\"card-body\">\n ${statusHtml}\n\n <div class=\"form-group\" style=\"margin-bottom:var(--space-lg);\">\n <label class=\"form-label\" style=\"font-weight:600; margin-bottom:8px;\">Synchronization Toggle</label>\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\">\n <input type=\"checkbox\" id=\"toggle-folder-sync\" ${isEnabled ? 'checked' : ''} ${(!isSupported || isLocalCompany) ? 'disabled' : ''} style=\"width:20px; height:20px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\" />\n <div>\n <span style=\"font-weight:500;\">Enable Direct Local Folder Storage</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">\n ${isLocalCompany ? 'Mandatory for offline/local company profile storage.' : 'Saves all data records and attachments straight to your machine.'}\n </div>\n </div>\n </label>\n </div>\n\n ${isEnabled && isSupported && !isCapacitor ? `\n <div style=\"display:flex; gap:12px; flex-wrap:wrap; border-top:1px solid var(--border-color); padding-top:var(--space-lg);\">\n <button class=\"btn btn-secondary\" id=\"btn-pick-dir\">\n <span class=\"material-icons-outlined\">folder</span> Choose Sync Folder...\n </button>\n ${hasHandle && isPermissionGranted ? `\n <button class=\"btn btn-secondary\" id=\"btn-force-sync\" data-tooltip=\"Overwrite folder JSONs with current memory cache\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">sync</span> Sync Now\n </button>\n ${!isLocalCompany ? `\n <button class=\"btn btn-danger\" id=\"btn-disconnect-dir\">\n <span class=\"material-icons-outlined\">link_off</span> Disconnect Folder\n </button>\n ` : ''}\n ` : ''}\n </div>\n ` : ''}\n </div>\n </div>\n\n <!-- Instructions Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>How Self-Hosting Works</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p>\n By linking a local folder, you establish a <strong>serverless self-hosted data hub</strong>.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">storage</span>\n <span><strong>Readable Data:</strong> Your jobs, quotes, and company configurations are saved in <code>data/*.json</code> files.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">photo_library</span>\n <span><strong>Physical Attachments:</strong> Uploaded photos and PDF catalogs are saved as standard image/PDF files under the <code>documents/</code> subfolder.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">sync_alt</span>\n <span><strong>Cloud Backups:</strong> Select a folder synced to <strong>OneDrive, Google Drive, or Dropbox</strong> to automatically back up and sync your data to the cloud.</span>\n </div>\n </div>\n </div>\n </div>\n `;\n\n // Event Listeners\n const toggle = tc.querySelector('#toggle-folder-sync');\n toggle?.addEventListener('change', async (e) => {\n if (isLocalCompany) {\n e.preventDefault();\n toggle.checked = true;\n return;\n }\n\n if (isCapacitor) {\n await store.setLocalDirectory(e.target.checked ? {} : null);\n showToast(e.target.checked ? 'Capacitor folder sync activated' : 'Folder sync deactivated', 'success');\n render();\n return;\n }\n\n if (e.target.checked) {\n // Trigger directory picker\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory configured successfully', 'success');\n } catch (err) {\n console.error('Directory selection cancelled or failed:', err);\n toggle.checked = false;\n showToast('Folder selection cancelled', 'info');\n }\n } else {\n await store.setLocalDirectory(null);\n showToast('Direct directory synchronization disabled', 'info');\n }\n render();\n });\n\n tc.querySelector('#btn-pick-dir')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory updated successfully', 'success');\n render();\n } catch (err) {\n console.error(err);\n }\n });\n\n tc.querySelector('#btn-reauthorize-dir')?.addEventListener('click', async () => {\n const granted = await store.verifyDirPermission(true);\n if (granted) {\n showToast('Folder access re-authorized successfully', 'success');\n } else {\n showToast('Failed to acquire write permissions', 'error');\n }\n render();\n });\n\n tc.querySelector('#btn-disconnect-dir')?.addEventListener('click', async () => {\n await store.setLocalDirectory(null);\n showToast('Local folder disconnected', 'info');\n render();\n });\n\n tc.querySelector('#btn-force-sync')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n btn.disabled = true;\n showToast('Writing cache data to local directory...', 'info');\n try {\n const collections = Object.keys(store.cache);\n await Promise.all(collections.map(col => store.writeCollectionToFolder(col, store.cache[col])));\n showToast('Folder sync completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Folder sync failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n }\n });\n }\n\n render();\n }\n\n function renderApiKeysTab(tc) {\n const s = store.getSettings();\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const ai = s.ai || {\n enabled: false,\n apiKey: '',\n visionApiKey: '',\n useSameKey: true,\n endpoint: 'https://api.deepseek.com/chat/completions',\n model: 'deepseek-chat',\n visionEndpoint: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',\n visionModel: 'gemini-2.0-flash',\n systemPrompt: 'You are Deputy, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n };\n if (ai.useSameKey === undefined) ai.useSameKey = true;\n const maps = s.maps || { apiKey: '' };\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null') || { id: 'default' };\n const userId = currentUser.id || 'default';\n const factsheetKey = `relay_factsheet_${userId}`;\n const enabledKey = `relay_factsheet_enabled_${userId}`;\n const factsheetVal = localStorage.getItem(factsheetKey) || '';\n const memoryEnabled = localStorage.getItem(enabledKey) !== 'false';\n\n function render() {\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg);\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>AI Assistant Configuration</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n \n <div class=\"form-group\">\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:pointer;\">\n <input type=\"checkbox\" id=\"ai-enabled\" ${ai.enabled ? 'checked' : ''} style=\"width:20px; height:20px; cursor:pointer;\" />\n <div>\n <span style=\"font-weight:600;\">Enable AI Assistant</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">Replaces the basic rule-based Relay co-pilot with a smart conversational LLM.</div>\n </div>\n </label>\n </div>\n\n <div id=\"ai-fields\" style=\"display: ${ai.enabled ? 'flex' : 'none'}; flex-direction:column; gap:16px; border-top:1px solid var(--border-color); padding-top:16px;\">\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Text API Key</label>\n <input type=\"password\" class=\"form-input\" id=\"ai-apikey\" value=\"${ai.apiKey || ''}\" placeholder=\"${isLocalMode ? 'sk-...' : 'Optional — Defaults to secure cloud API key'}\" />\n </div>\n <div class=\"form-group\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <label class=\"form-label\" style=\"font-weight:600; margin-bottom:0;\">Vision API Key</label>\n <label style=\"display:flex; align-items:center; gap:6px; font-size:12px; cursor:pointer;\">\n <input type=\"checkbox\" id=\"ai-use-same-key\" ${ai.useSameKey ? 'checked' : ''} style=\"width:14px; height:14px;\" /> Use Text API Key\n </label>\n </div>\n <input type=\"password\" class=\"form-input\" id=\"ai-vision-apikey\" value=\"${ai.useSameKey ? (ai.apiKey || '') : (ai.visionApiKey || '')}\" placeholder=\"API Key for Vision Model\" style=\"margin-top:6px; ${ai.useSameKey ? 'opacity:0.5; pointer-events:none;' : ''}\" />\n </div>\n </div>\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:-8px;\">${isLocalMode ? 'Your API keys are stored locally in your browser/sync directory.' : 'Keys are optional. If blank, cloud operations will route securely through Edge Functions.'}</div>\n\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Text API Endpoint</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-endpoint\" value=\"${ai.endpoint || 'https://api.deepseek.com/chat/completions'}\" placeholder=\"https://api.deepseek.com/chat/completions\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Text Model Name</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-model\" value=\"${ai.model || 'deepseek-chat'}\" placeholder=\"deepseek-chat\" />\n </div>\n </div>\n\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Vision API Endpoint (Multimodal)</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-vision-endpoint\" value=\"${ai.visionEndpoint || 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions'}\" placeholder=\"https://api.openai.com/v1/chat/completions\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Vision Model Name</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-vision-model\" value=\"${ai.visionModel || 'gemini-2.0-flash'}\" placeholder=\"gpt-4o-mini\" />\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Base System Prompt</label>\n <textarea class=\"form-input\" id=\"ai-systemprompt\" rows=\"4\" style=\"font-family:inherit; resize:vertical;\">${ai.systemPrompt || ''}</textarea>\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.</div>\n </div>\n\n <div style=\"display:flex; gap:12px; align-items:center; margin-top:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-test-ai\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">bolt</span> Test Connection\n </button>\n <span id=\"test-status\" style=\"font-size:13px; font-weight:500;\"></span>\n </div>\n <div id=\"test-error-details\" style=\"display:none; margin-top:8px; font-size:12px; color:var(--color-danger); background:var(--color-danger-bg); border-left:3px solid var(--color-danger); padding:8px; border-radius:4px; line-height:1.4;\"></div>\n </div>\n\n <div style=\"border-top:1px solid var(--border-color); padding-top:16px; display:flex; justify-content:flex-end;\">\n <button class=\"btn btn-primary\" id=\"btn-save-ai\">Save AI Config</button>\n </div>\n\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Google Maps Configuration</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Maps API Key</label>\n <input type=\"password\" class=\"form-input\" id=\"maps-apikey\" value=\"${maps.apiKey || ''}\" placeholder=\"AIzaSy...\" />\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">Used for address autocomplete, distance calculations, and rendering maps. Get this from the Google Cloud Console.</div>\n </div>\n <div style=\"border-top:1px solid var(--border-color); padding-top:16px; display:flex; justify-content:flex-end;\">\n <button class=\"btn btn-primary\" id=\"btn-save-maps\">Save Maps Config</button>\n </div>\n </div>\n </div>\n\n </div>\n\n <!-- Cheapest Smart AI Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>Cheapest Smart AI Guide</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p style=\"margin:0;\">\n <strong>DeepSeek</strong> is one of the most cost-efficient and high-performance language models currently available, making it perfect for lightweight co-pilot tasks.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">key</span>\n <span><strong>API Key:</strong> Get your key from <a href=\"https://platform.deepseek.com/\" target=\"_blank\" style=\"color:var(--color-primary); text-decoration:underline;\">platform.deepseek.com</a>. New accounts typically receive free trial credits.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">security</span>\n <span><strong>CORS Restrictions:</strong> Due to browser security, direct API calls to DeepSeek may be blocked. To bypass this:\n <ul style=\"margin:4px 0 0 16px; padding:0; list-style:disc; font-size:12px;\">\n <li>Route via a CORS proxy (e.g. <code>https://cors-anywhere.herokuapp.com/</code> prefix).</li>\n <li>Connect via an aggregator like <strong>OpenRouter</strong> (API Endpoint: <code>https://openrouter.ai/api/v1/chat/completions</code>).</li>\n <li>Use a local LLM runner like <strong>Ollama</strong> (API Endpoint: <code>http://localhost:11434/v1/chat/completions</code>).</li>\n </ul>\n </span>\n </div>\n </div>\n </div>\n\n </div>\n `;\n\n // Toggle display of fields based on enabled checkbox\n const enabledCheckbox = tc.querySelector('#ai-enabled');\n const fieldsContainer = tc.querySelector('#ai-fields');\n enabledCheckbox.addEventListener('change', (e) => {\n fieldsContainer.style.display = e.target.checked ? 'flex' : 'none';\n });\n\n const useSameKeyCheckbox = tc.querySelector('#ai-use-same-key');\n const visionApiKeyInput = tc.querySelector('#ai-vision-apikey');\n const textApiKeyInput = tc.querySelector('#ai-apikey');\n if (useSameKeyCheckbox && visionApiKeyInput) {\n useSameKeyCheckbox.addEventListener('change', (e) => {\n if (e.target.checked) {\n visionApiKeyInput.style.opacity = '0.5';\n visionApiKeyInput.style.pointerEvents = 'none';\n if (textApiKeyInput) visionApiKeyInput.value = textApiKeyInput.value;\n } else {\n visionApiKeyInput.style.opacity = '1';\n visionApiKeyInput.style.pointerEvents = 'auto';\n visionApiKeyInput.focus();\n }\n });\n \n if (textApiKeyInput) {\n textApiKeyInput.addEventListener('input', (e) => {\n if (useSameKeyCheckbox.checked) {\n visionApiKeyInput.value = e.target.value;\n }\n });\n }\n }\n\n\n\n // Save handler\n tc.querySelector('#btn-save-ai').addEventListener('click', () => {\n const enabled = enabledCheckbox.checked;\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const useSameKey = tc.querySelector('#ai-use-same-key') ? tc.querySelector('#ai-use-same-key').checked : true;\n const visionApiKey = useSameKey ? apiKey : tc.querySelector('#ai-vision-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const visionEndpoint = tc.querySelector('#ai-vision-endpoint').value.trim();\n const visionModel = tc.querySelector('#ai-vision-model').value.trim();\n const systemPrompt = tc.querySelector('#ai-systemprompt').value.trim();\n\n if (isLocalMode && enabled && !apiKey) {\n showToast('Text API Key is required when enabling the AI assistant.', 'error');\n return;\n }\n\n const settings = store.getSettings();\n settings.ai = {\n enabled,\n apiKey,\n visionApiKey,\n useSameKey,\n endpoint,\n model,\n visionEndpoint,\n visionModel,\n systemPrompt\n };\n\n store.saveSettings(settings);\n showToast('AI settings saved successfully', 'success');\n });\n\n tc.querySelector('#btn-save-maps').addEventListener('click', () => {\n const apiKey = tc.querySelector('#maps-apikey').value.trim();\n const settings = store.getSettings();\n settings.maps = { apiKey };\n store.saveSettings(settings);\n showToast('Maps settings saved successfully', 'success');\n });\n\n // Test handler\n tc.querySelector('#btn-test-ai').addEventListener('click', async () => {\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const statusEl = tc.querySelector('#test-status');\n const errEl = tc.querySelector('#test-error-details');\n\n const inElectron = !!(window.electronAPI && window.electronAPI.callDeepSeek);\n const hasEnvKey = !!(import.meta.env.VITE_DEEPSEEK_API_KEY);\n\n if (!apiKey && !inElectron && !hasEnvKey) {\n showToast('Enter an API key to test.', 'error');\n return;\n }\n\n statusEl.style.color = 'var(--text-secondary)';\n statusEl.textContent = 'Testing connection...';\n errEl.style.display = 'none';\n\n const startTime = Date.now();\n try {\n if (inElectron) {\n const data = await window.electronAPI.callDeepSeek({\n messages: [{ role: 'user', content: 'respond only with the word OK' }],\n endpoint,\n model,\n apiKey // Pass typed key if any\n });\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success (via Electron IPC)! Latency: ${latency}ms (Reply: \"${reply}\")`;\n } else {\n const res = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY}`\n },\n body: JSON.stringify({\n model: model || 'deepseek-chat',\n messages: [\n { role: 'user', content: 'respond only with the word OK' }\n ],\n max_tokens: 5\n })\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`HTTP ${res.status}: ${body}`);\n }\n\n const data = await res.json();\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success! Latency: ${latency}ms (Reply: \"${reply}\")`;\n }\n } catch (err) {\n console.error('AI test connection failed:', err);\n statusEl.style.color = 'var(--color-danger)';\n statusEl.textContent = 'Test failed';\n errEl.style.display = 'block';\n\n let errMsg = err.message;\n if (err.name === 'TypeError' && err.message.includes('Failed to fetch')) {\n errMsg = 'CORS Blocked or Offline. The browser blocked this cross-origin request. Please use a CORS proxy, an API gateway like OpenRouter, or check your internet connection.';\n }\n errEl.textContent = errMsg;\n }\n });\n }\n\n render();\n }\n\n function renderCostCentersTab(tc) {\n const list = store.getAll('costCenters') || [];\n\n const renderTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:900px\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Cost Centers</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-cost-center\" style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span> Add Cost Center\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"padding-left:16px; width:120px\">Code</th>\n <th>Name</th>\n <th>Xero Mapping</th>\n <th style=\"width:120px\">Status</th>\n <th style=\"text-align:right; padding-right:16px; width:180px\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${list.length === 0 ? `\n <tr>\n <td colspan=\"5\" style=\"text-align:center; padding:32px 16px; color:var(--text-secondary)\">\n No Cost Centers configured. Click \"Add Cost Center\" to create one.\n </td>\n </tr>\n ` : list.map(cc => `\n <tr>\n <td style=\"padding-left:16px; font-weight:600\">${escapeHTML(cc.code)}</td>\n <td>${escapeHTML(cc.name)}</td>\n <td>\n ${cc.xeroSalesAccountCode || cc.xeroTrackingOptionName ? `\n <div style=\"font-size:11px; line-height:1.4\">\n ${cc.xeroSalesAccountCode ? `<div><span class=\"text-tertiary\">Sales Code:</span> <strong>${escapeHTML(cc.xeroSalesAccountCode)}</strong></div>` : ''}\n ${cc.xeroExpenseAccountCode ? `<div><span class=\"text-tertiary\">Expense Code:</span> <strong>${escapeHTML(cc.xeroExpenseAccountCode)}</strong></div>` : ''}\n ${cc.xeroTrackingOptionName ? `<div><span class=\"text-tertiary\">Tracking:</span> <strong>${escapeHTML(cc.xeroTrackingCategoryName || 'Department')}:${escapeHTML(cc.xeroTrackingOptionName)}</strong></div>` : ''}\n </div>\n ` : '<span class=\"text-tertiary\" style=\"font-size:11px\">— Unmapped —</span>'}\n </td>\n <td>\n <span class=\"badge ${cc.active ? 'badge-success' : 'badge-neutral'}\">\n ${cc.active ? 'Active' : 'Inactive'}\n </span>\n </td>\n <td style=\"text-align:right; padding-right:16px\">\n <button class=\"btn btn-secondary btn-sm btn-edit-cc\" data-id=\"${cc.id}\" style=\"margin-right:6px\">Edit</button>\n <button class=\"btn btn-outline btn-sm btn-toggle-cc\" data-id=\"${cc.id}\">\n ${cc.active ? 'Disable' : 'Enable'}\n </button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-cost-center')?.addEventListener('click', () => openCostCenterModal());\n tc.querySelectorAll('.btn-edit-cc').forEach(btn => {\n btn.addEventListener('click', (e) => openCostCenterModal(e.target.dataset.id));\n });\n tc.querySelectorAll('.btn-toggle-cc').forEach(btn => {\n btn.addEventListener('click', async (e) => {\n const id = e.target.dataset.id;\n const cc = store.getById('costCenters', id);\n if (cc) {\n const newActive = !cc.active;\n await store.update('costCenters', id, { active: newActive });\n showToast(`Cost center ${cc.name} has been ${newActive ? 'enabled' : 'disabled'}.`, 'success');\n renderCostCentersTab(tc);\n }\n });\n });\n };\n\n renderTab();\n }\n\n function openCostCenterModal(editId = null) {\n let cc = editId ? store.getById('costCenters', editId) : { \n code: '', \n name: '', \n active: true,\n xeroSalesAccountCode: '',\n xeroExpenseAccountCode: '',\n xeroTrackingCategoryName: 'Department',\n xeroTrackingOptionName: ''\n };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\" style=\"margin-bottom:16px\">\n <label class=\"form-label\" style=\"display:block; margin-bottom:6px\">Cost Center Code</label>\n <input class=\"form-input\" id=\"cc-code\" value=\"${escapeHTML(cc.code)}\" placeholder=\"e.g. ELEC\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\" style=\"margin-bottom:16px\">\n <label class=\"form-label\" style=\"display:block; margin-bottom:6px\">Cost Center Name</label>\n <input class=\"form-input\" id=\"cc-name\" value=\"${escapeHTML(cc.name)}\" placeholder=\"e.g. Electrical Services\" style=\"width:100%\" />\n </div>\n \n <fieldset style=\"border: 1px solid var(--border-color); border-radius: 6px; padding: 12px; margin-bottom: 16px; background:var(--card-bg)\">\n <legend style=\"padding: 0 6px; font-size: 11px; font-weight: 600; color: var(--color-primary); display: flex; align-items: center; gap: 4px; margin: 0\">\n <span class=\"material-icons-outlined\" style=\"font-size:15px\">sync</span> Xero Integration (Optional)\n </legend>\n <div class=\"form-row\" style=\"margin-bottom:12px; display:grid; grid-template-columns:1fr 1fr; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Sales Account Code</label>\n <input class=\"form-input\" id=\"cc-xero-sales\" value=\"${escapeHTML(cc.xeroSalesAccountCode || '')}\" placeholder=\"e.g. 200\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Expense Account Code</label>\n <input class=\"form-input\" id=\"cc-xero-expense\" value=\"${escapeHTML(cc.xeroExpenseAccountCode || '')}\" placeholder=\"e.g. 300\" style=\"width:100%\" />\n </div>\n </div>\n <div class=\"form-row\" style=\"display:grid; grid-template-columns:1fr 1fr; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Tracking Category</label>\n <input class=\"form-input\" id=\"cc-xero-category\" value=\"${escapeHTML(cc.xeroTrackingCategoryName || 'Department')}\" placeholder=\"e.g. Department\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Tracking Option</label>\n <input class=\"form-input\" id=\"cc-xero-option\" value=\"${escapeHTML(cc.xeroTrackingOptionName || '')}\" placeholder=\"e.g. Electrical\" style=\"width:100%\" />\n </div>\n </div>\n </fieldset>\n\n <div class=\"form-group\">\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer\">\n <input type=\"checkbox\" id=\"cc-active\" ${cc.active ? 'checked' : ''} style=\"width:16px; height:16px; margin:0\" />\n <span>Active</span>\n </label>\n </div>\n `;\n\n showModal({\n title: editId ? 'Edit Cost Center' : 'Add Cost Center',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-cc', onClick: async (c) => {\n const code = document.getElementById('cc-code').value.trim().toUpperCase();\n const name = document.getElementById('cc-name').value.trim();\n const active = document.getElementById('cc-active').checked;\n const xeroSalesAccountCode = document.getElementById('cc-xero-sales').value.trim();\n const xeroExpenseAccountCode = document.getElementById('cc-xero-expense').value.trim();\n const xeroTrackingCategoryName = document.getElementById('cc-xero-category').value.trim();\n const xeroTrackingOptionName = document.getElementById('cc-xero-option').value.trim();\n\n if (!code) { showToast('Code required', 'error'); return; }\n if (!name) { showToast('Name required', 'error'); return; }\n\n try {\n const updates = { \n code, \n name, \n active,\n xeroSalesAccountCode: xeroSalesAccountCode || null,\n xeroExpenseAccountCode: xeroExpenseAccountCode || null,\n xeroTrackingCategoryName: xeroTrackingCategoryName || null,\n xeroTrackingOptionName: xeroTrackingOptionName || null\n };\n if (editId) {\n await store.update('costCenters', editId, updates);\n showToast('Cost Center updated successfully', 'success');\n } else {\n await store.create('costCenters', updates);\n showToast('Cost Center created successfully', 'success');\n }\n c();\n const tc = document.querySelector('#settings-content');\n renderCostCentersTab(tc);\n } catch (err) {\n console.error('Error saving cost center:', err);\n showToast('Failed to save cost center: ' + err.message, 'error');\n }\n }}\n ]\n });\n }\n\n function renderSuppliersSettings(tc) {\n const settings = store.getSettings();\n const categories = settings.supplierCategories || ['Electrical', 'Plumbing', 'HVAC', 'Fire Safety', 'Security', 'General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Supplier Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Define classifications/categories for your suppliers (e.g. Electrical, Plumbing, HVAC). These categories are used to group suppliers in the Suppliers directory.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"supplier-categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${escapeHTML(c)}\n <span class=\"material-icons-outlined btn-remove-supplier-cat\" data-name=\"${escapeHTML(c)}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-supplier-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-suppliers\" data-tooltip=\"Save supplier categories settings\" data-tooltip-pos=\"top\">Save Supplier Settings</button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-supplier-category').addEventListener('click', () => {\n const name = prompt('Enter supplier category name:');\n if (name) {\n const trimmed = name.trim();\n if (!trimmed) return;\n const exists = Array.from(tc.querySelectorAll('.btn-remove-supplier-cat')).some(span => span.dataset.name.toLowerCase() === trimmed.toLowerCase());\n if (exists) {\n showToast('Category already exists', 'error');\n return;\n }\n\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${escapeHTML(trimmed)}\n <span class=\"material-icons-outlined btn-remove-supplier-cat\" data-name=\"${escapeHTML(trimmed)}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#supplier-categories-container').insertBefore(btn, tc.querySelector('#btn-add-supplier-category'));\n btn.querySelector('.btn-remove-supplier-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-supplier-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-suppliers').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-suppliers');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const supplierCategories = Array.from(tc.querySelectorAll('.btn-remove-supplier-cat')).map(span => span.dataset.name);\n const updatedSettings = {\n ...settings,\n supplierCategories\n };\n await store.saveSettings(updatedSettings);\n showToast('Supplier settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving supplier settings:', err);\n showToast('Failed to save supplier settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n }\n"}, "e959b83ab55ea5abe482b1857499bc19c1afd50b": {"t": 1785754174.9299204, "n": 285185, "h": "2f84431234ab2263", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData, seedData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\nimport { FLAGS } from '../utils/flags.js';\nimport { applyTheme, THEMES } from '../utils/theme.js';\nimport { storageGet, storageSet } from '../utils/tauriStore.js';\n\n// Compress uploaded images using Canvas to avoid huge Base64 data payloads\nfunction compressImage(dataUrl, maxWidth, maxHeight) {\n return new Promise((resolve) => {\n if (!dataUrl) {\n resolve(dataUrl);\n return;\n }\n // SVG vectors are already lightweight; do not compress them\n if (dataUrl.startsWith('data:image/svg+xml')) {\n resolve(dataUrl);\n return;\n }\n const img = new Image();\n img.onload = () => {\n let width = img.width;\n let height = img.height;\n\n if (width > height) {\n if (width > maxWidth) {\n height = Math.round((height * maxWidth) / width);\n width = maxWidth;\n }\n } else {\n if (height > maxHeight) {\n width = Math.round((width * maxHeight) / height);\n height = maxHeight;\n }\n }\n\n const canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n\n const ctx = canvas.getContext('2d');\n ctx.drawImage(img, 0, 0, width, height);\n\n const isPng = dataUrl.includes('image/png');\n const format = isPng ? 'image/png' : 'image/jpeg';\n const quality = isPng ? undefined : 0.85;\n\n resolve(canvas.toDataURL(format, quality));\n };\n img.onerror = () => {\n resolve(dataUrl);\n };\n img.src = dataUrl;\n });\n}\n\n// Helper to hash password using SHA-256 Web Crypto API\nasync function hashPassword(password) {\n const msgBuffer = new TextEncoder().encode(password);\n const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);\n const hashArray = Array.from(new Uint8Array(hashBuffer));\n return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');\n}\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n const localDeploymentType = settings.localDeploymentType || 'single_user';\n\n const isUsersDisabled = isLocalMode && localDeploymentType === 'single_user';\n const isPortalDisabled = isLocalMode;\n const isFolderSyncDisabled = !isLocalMode;\n\n if (isUsersDisabled && activeTab === 'users') {\n activeTab = 'company';\n }\n if (isPortalDisabled && activeTab === 'portal') {\n activeTab = 'company';\n }\n if (isPortalDisabled && activeTab === 'portal_contractor') {\n activeTab = 'company';\n }\n if (isFolderSyncDisabled && activeTab === 'folder_sync') {\n activeTab = 'company';\n }\n \n const isAIAssistantDisabled = isLocalMode;\n if (isAIAssistantDisabled && activeTab === 'ai_assistant') {\n activeTab = 'company';\n }\n\n function getCategoryForTab(tab) {\n if (['company', 'portal', 'portal_contractor', 'folder_sync', 'ai_assistant', 'system'].includes(tab)) return 'general';\n if (['templates_forms', 'invoices_quotes', 'payments'].includes(tab)) return 'workflow';\n if (['users', 'suppliers'].includes(tab)) return 'people';\n if (['materials', 'cost_centers', 'tax'].includes(tab)) return 'resources';\n return 'general';\n }\n\n let activeCategory = getCategoryForTab(activeTab);\n\n const openMigrationModal = () => {\n const modalContent = document.createElement('div');\n modalContent.innerHTML = `\n <form id=\"convert-cloud-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Cloud Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a local/offline account. All data will be migrated to the secure cloud database.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"migrate-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"Company Name\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"migrate-admin-name\" required placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Phone Number</label>\n <input class=\"form-input\" id=\"migrate-admin-phone\" required placeholder=\"e.g. 0412 345 678\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Email Address (Username)</label>\n <input class=\"form-input\" type=\"email\" id=\"migrate-admin-email\" required placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Password</label>\n <input class=\"form-input\" type=\"password\" id=\"migrate-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"migration-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"migration-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-migrate-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-migrate-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" id=\"submit-icon\" style=\"font-size:18px;\">cloud_done</span>\n <span id=\"submit-text\">Register & Start Migration</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: 'Register & Migrate to Cloud',\n content: modalContent,\n size: 'modal-md'\n });\n\n modalContent.querySelector('#btn-migrate-cancel').addEventListener('click', close);\n\n const form = modalContent.querySelector('#convert-cloud-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n\n const errorEl = modalContent.querySelector('#migration-error');\n const errorTextEl = modalContent.querySelector('#migration-error-text');\n const submitBtn = modalContent.querySelector('#btn-migrate-submit');\n const cancelBtn = modalContent.querySelector('#btn-migrate-cancel');\n const submitText = modalContent.querySelector('#submit-text');\n const submitIcon = modalContent.querySelector('#submit-icon');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n submitText.textContent = 'Migrating to Cloud...';\n submitIcon.className = 'material-icons-outlined spinner';\n submitIcon.textContent = 'sync';\n submitIcon.style.animation = 'spin 1s linear infinite';\n\n const companyName = modalContent.querySelector('#migrate-company-name').value.trim();\n const adminName = modalContent.querySelector('#migrate-admin-name').value.trim();\n const adminPhone = modalContent.querySelector('#migrate-admin-phone').value.trim();\n const email = modalContent.querySelector('#migrate-admin-email').value.trim();\n const password = modalContent.querySelector('#migrate-admin-password').value;\n\n try {\n const settings = store.getSettings();\n settings.name = companyName;\n await store.saveSettings(settings);\n\n const { supabase } = await import('../utils/supabase.js');\n\n const { data: authData, error: authErr } = await supabase.auth.signUp({\n email,\n password,\n options: {\n data: {\n name: adminName,\n phone: adminPhone\n }\n }\n });\n if (authErr) throw authErr;\n\n if (!authData.user) {\n throw new Error('Verification required or signup was blocked. Check your email inbox.');\n }\n\n const { data: companyId, error: rpcError } = await supabase.rpc('create_company_and_admin', {\n user_id: authData.user.id,\n company_name: companyName,\n admin_name: adminName,\n admin_phone: adminPhone\n });\n if (rpcError) throw rpcError;\n\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n await store.migrateLocalToCloud(companyId, authData.user.id);\n\n if (activeAccountId) {\n const localAccountsKey = 'relay_accounts';\n let localAccounts = [];\n try {\n const stored = localStorage.getItem(localAccountsKey);\n if (stored) {\n localAccounts = JSON.parse(stored);\n }\n } catch (e) {\n console.error('Error reading local accounts:', e);\n }\n localAccounts = localAccounts.filter(a => a.id !== activeAccountId);\n localStorage.setItem(localAccountsKey, JSON.stringify(localAccounts));\n\n store.deleteLocalAccountData(activeAccountId);\n }\n\n const { data: profile, error: profileErr } = await supabase\n .from('profiles')\n .select('*')\n .eq('id', authData.user.id)\n .single();\n if (profileErr) throw profileErr;\n\n const user = {\n id: profile.id,\n companyId: profile.company_id,\n name: profile.name,\n role: profile.role,\n userTypeName: 'Admin',\n userTypeId: `${profile.company_id}_ut_admin`,\n color: profile.color || '#FF5C00',\n theme: 'light'\n };\n\n localStorage.setItem('currentUser', JSON.stringify(user));\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Migration completed successfully! Redirecting...', 'success');\n close();\n\n setTimeout(() => {\n window.location.hash = '#/';\n window.location.reload();\n }, 1500);\n\n } catch (err) {\n console.error('Migration failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during migration.';\n errorEl.style.display = 'flex';\n\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n submitText.textContent = 'Register & Start Migration';\n submitIcon.className = 'material-icons-outlined';\n submitIcon.textContent = 'cloud_done';\n submitIcon.style.animation = '';\n }\n });\n };\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n const categories = [\n {\n id: 'general',\n label: 'General',\n icon: 'settings',\n tabs: [\n { id: 'company', label: 'Company Profile' },\n { id: 'portal', label: 'Customer Portal', disabled: isPortalDisabled, tooltip: 'Requires Cloud Account' },\n { id: 'portal_contractor', label: 'Contractor Portal', disabled: isPortalDisabled, tooltip: 'Requires Cloud Account' },\n { id: 'folder_sync', label: 'Folder Sync', disabled: isFolderSyncDisabled, tooltip: 'Requires Local Folder Storage' },\n { id: 'api_keys', label: 'Integrations & APIs' },\n { id: 'system', label: 'System Options' }\n ]\n },\n {\n id: 'workflow',\n label: 'Workflow',\n icon: 'account_tree',\n tabs: [\n { id: 'templates_forms', label: 'Templates & Forms' },\n { id: 'invoices_quotes', label: 'Quotes & Invoices' },\n // v1.3 #3 — dark until FLAGS.payments flips on for launch\n ...(FLAGS.payments ? [{ id: 'payments', label: 'Payments' }] : [])\n ]\n },\n {\n id: 'people',\n label: 'People',\n icon: 'groups',\n tabs: [\n { id: 'users', label: 'Users & Permissions', disabled: isUsersDisabled, tooltip: 'Requires Cloud Account or Local Server' },\n { id: 'suppliers', label: 'Suppliers Configuration' }\n ]\n },\n {\n id: 'resources',\n label: 'Resources',\n icon: 'widgets',\n tabs: [\n { id: 'materials', label: 'Materials & Kits' },\n { id: 'cost_centers', label: 'Cost Centers & Xero' },\n { id: 'tax', label: 'Tax & Labor Rates' }\n ]\n }\n ];\n\n function render() {\n const categoryTabsHtml = categories.map(cat => {\n const isActive = activeCategory === cat.id;\n return `\n <button class=\"settings-category-btn ${isActive ? 'active' : ''}\" data-category=\"${cat.id}\">\n <span class=\"material-icons-outlined\">${cat.icon}</span>\n <span>${cat.label}</span>\n </button>\n `;\n }).join('');\n\n const activeCatData = categories.find(cat => cat.id === activeCategory) || categories[0];\n const subTabsHtml = activeCatData.tabs.map(tab => {\n const isActive = activeTab === tab.id;\n const isDisabled = tab.disabled;\n const tooltipAttr = isDisabled ? `data-tooltip=\"${tab.tooltip}\" data-tooltip-pos=\"top\"` : '';\n return `\n <button class=\"settings-tab-btn ${isActive ? 'active' : ''} ${isDisabled ? 'disabled-local' : ''}\" data-tab=\"${tab.id}\" ${tooltipAttr}>\n ${tab.label}\n </button>\n `;\n }).join('');\n\n container.innerHTML = `\n <style>\n .settings-nav-categories {\n display: flex;\n gap: 8px;\n border-bottom: 2px solid var(--border-color);\n padding-bottom: 12px;\n margin-bottom: 20px;\n }\n .settings-category-btn {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 10px 20px;\n font-size: 15px;\n font-weight: 600;\n color: var(--text-secondary);\n background: transparent;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n .settings-category-btn .material-icons-outlined {\n font-size: 20px;\n transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n .settings-category-btn:hover {\n color: var(--text-primary);\n background: var(--bg-color);\n }\n .settings-category-btn:hover .material-icons-outlined {\n transform: scale(1.08);\n }\n .settings-category-btn.active {\n color: white;\n background: var(--color-primary);\n box-shadow: 0 4px 14px rgba(255, 92, 0, 0.25);\n transform: translateY(-1px);\n }\n \n .settings-nav-tabs {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-bottom: 24px;\n background: var(--bg-color);\n padding: 6px;\n border-radius: 10px;\n border: 1px solid var(--border-color);\n animation: subtabs-slide-in 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);\n }\n @keyframes subtabs-slide-in {\n from {\n opacity: 0;\n transform: translateX(-10px);\n }\n to {\n opacity: 1;\n transform: translateX(0);\n }\n }\n .settings-tab-btn {\n padding: 8px 16px;\n font-size: var(--font-size-sm);\n font-weight: 500;\n color: var(--text-secondary);\n background: transparent;\n border: none;\n border-radius: 20px;\n cursor: pointer;\n transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n .settings-tab-btn:hover:not(.disabled-local) {\n color: var(--text-primary);\n background: rgba(128, 128, 128, 0.08);\n }\n .settings-tab-btn.active {\n color: var(--color-primary);\n background: var(--content-bg);\n font-weight: 600;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);\n transform: scale(1.02);\n }\n \n #settings-content {\n animation: settings-fade-in 0.28s cubic-bezier(0.4, 0, 0.2, 1);\n }\n @keyframes settings-fade-in {\n from {\n opacity: 0;\n transform: translateY(6px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n }\n </style>\n\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"settings-nav-categories\">\n ${categoryTabsHtml}\n </div>\n\n <div class=\"settings-nav-tabs\">\n ${subTabsHtml}\n </div>\n\n <div id=\"settings-content\" style=\"padding-top:var(--space-sm)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.settings-category-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n const catId = btn.dataset.category;\n if (activeCategory === catId) return;\n\n activeCategory = catId;\n const catData = categories.find(c => c.id === catId);\n const firstActiveTab = catData.tabs.find(t => !t.disabled) || catData.tabs[0];\n activeTab = firstActiveTab.id;\n\n render();\n });\n });\n\n container.querySelectorAll('.settings-tab-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n if (btn.classList.contains('disabled-local')) return;\n activeTab = btn.dataset.tab;\n render();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'suppliers') {\n renderSuppliersSettings(tc);\n return;\n }\n\n if (activeTab === 'folder_sync') {\n renderFolderSyncTab(tc);\n return;\n }\n\n if (activeTab === 'api_keys') {\n renderApiKeysTab(tc);\n return;\n }\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'payments') {\n renderPaymentsTab(tc);\n return;\n }\n\n if (activeTab === 'cost_centers') {\n renderCostCentersTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n\n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Standard Logo (Large) using Canvas to ~600x300 max bounding box\n compressImage(re.target.result, 600, 300).then((compressed) => {\n pendingLogo = compressed;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Small/Shrunk Logo using Canvas to ~200x100 max bounding box\n compressImage(re.target.result, 200, 100).then((compressed) => {\n pendingLogoSmall = compressed;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n\n\n tc.querySelector('#btn-save-company').addEventListener('click', async () => {\n const saveBtn = tc.querySelector('#btn-save-company');\n const originalHtml = saveBtn.innerHTML;\n saveBtn.disabled = true;\n saveBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving Changes...';\n\n try {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n await store.saveSettings(settings);\n showToast('Company information saved successfully to database', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n render();\n } catch (err) {\n console.error('Error saving company profile settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n saveBtn.disabled = false;\n saveBtn.innerHTML = originalHtml;\n }\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n const pendingResets = store.getAll('passwordResetRequests') || [];\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div style=\"background:rgba(59, 130, 246, 0.1); border-left:4px solid #3b82f6; padding:12px 16px; margin-bottom:var(--space-md); border-radius:4px; font-size:13px; color:#f8fafc; display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:#3b82f6; font-size:18px;\">info</span>\n <span>Your company login code is <strong style=\"color:#60a5fa\">${companySlug}</strong>. Technicians log in using <strong style=\"color:#60a5fa\">username@${companySlug}</strong>.</span>\n </span>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Username</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n ${ut?.id !== 'ut_admin' ? `\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n ` : ''}\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\">\n <h4 style=\"margin:0\">Pending Password Reset Requests</h4>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Username/Email</th>\n <th>Requested On</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:24px\">No pending reset requests</td></tr>' : ''}\n ${pendingResets.filter(r => r.status === 'pending').map(r => {\n const tech = techs.find(t => t.id === r.technician_id) || {};\n const requestedAt = r.requested_at ? new Date(r.requested_at).toLocaleString() : 'Unknown';\n return `\n <tr>\n <td class=\"font-medium\">${tech.name || 'Unknown'}</td>\n <td class=\"text-secondary\">${r.employee_id || tech.username || ''}</td>\n <td class=\"text-tertiary\">${requestedAt}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-approve-reset\" data-id=\"${r.id}\" data-tech-id=\"${tech.id}\">Approve &amp; Reset</button>\n <button class=\"btn btn-sm btn-ghost text-danger btn-deny-reset\" data-id=\"${r.id}\">Deny</button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n ${isLocalMode ? '' : `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `}\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => {\n openUserModal();\n });\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-approve-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const techId = e.currentTarget.dataset.techId;\n const t = store.getById('technicians', techId);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <p>Approve password reset request for <strong>${t.name}</strong>? Please enter their new password below:</p>\n <div class=\"form-group\" style=\"margin-top:12px\">\n <label class=\"form-label\">New Password</label>\n <input type=\"password\" id=\"admin-reset-pwd-input\" class=\"form-input\" placeholder=\"Min. 6 characters\" minlength=\"6\" autofocus />\n </div>\n `;\n showModal({\n title: 'Approve Password Reset',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reset Password', className: 'btn-primary', onClick: c => {\n const pwdInput = document.getElementById('admin-reset-pwd-input');\n const newPwd = pwdInput ? pwdInput.value : '';\n if (!newPwd || newPwd.length < 6) {\n showToast('Password must be at least 6 characters.', 'error');\n return;\n }\n \n // Update technician password\n store.update('technicians', techId, { password: newPwd });\n \n // Approve the request\n store.update('passwordResetRequests', id, { status: 'approved', updated_at: new Date().toISOString() });\n \n showToast(`Password updated for ${t.name}`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-deny-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n store.update('passwordResetRequests', id, { status: 'denied', updated_at: new Date().toISOString() });\n showToast('Password reset request denied', 'info');\n renderContent();\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n let currentJobTypes = settings.jobTypes ? [...settings.jobTypes] : ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'];\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg)\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax Rates</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\" style=\"display:flex; flex-direction:column; gap:8px\">\n <label class=\"form-label\" style=\"display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:0\">\n <input type=\"checkbox\" id=\"tax-enabled\" style=\"width:16px; height:16px; margin:0\" ${settings.taxEnabled !== false ? 'checked' : ''} />\n Enable GST / Sales Tax\n </label>\n <div style=\"display:${settings.taxEnabled !== false ? 'flex' : 'none'}; align-items:center; gap:8px; margin-top:4px\" id=\"tax-rate-container\">\n <input class=\"form-input\" id=\"tax-rate\" type=\"number\" value=\"${settings.taxRate !== undefined ? settings.taxRate : 10}\" style=\"width:100px\" min=\"0\" max=\"100\" step=\"0.1\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" id=\"job-types-card\">\n <div class=\"card-header\"><h4>Job Types</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:12px\">Define the types of jobs your team performs (e.g. Electrical, Plumbing). These appear in job forms and rate mapping.</p>\n <div id=\"job-types-list\" style=\"display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px;\">\n ${currentJobTypes.map(type => `\n <span class=\"tag-pill tag-pill-active\" style=\"display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:100px; font-weight:500; font-size:13px; background:var(--color-primary-light); color:var(--color-primary-dark)\">\n <span>${escapeHTML(type)}</span>\n <span class=\"material-icons-outlined delete-job-type-btn\" data-type=\"${escapeHTML(type)}\" style=\"font-size:16px; cursor:pointer; opacity:0.7; transition:opacity 0.2s\">close</span>\n </span>\n `).join('')}\n </div>\n <div style=\"display:flex; gap:8px;\">\n <input class=\"form-input\" id=\"new-job-type-input\" type=\"text\" placeholder=\"e.g. Renovation\" style=\"flex:1\" />\n <button class=\"btn btn-secondary\" id=\"add-job-type-btn\" style=\"display:flex; align-items:center; gap:4px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${escapeHTML(rate.name)}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${escapeHTML(rate.description || '')}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => {\n const active = applicable.includes(day);\n return `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${active ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'};background:${active ? 'var(--color-primary-light)' : 'transparent'};color:${active ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `}).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // Toggle tax rate container visibility\n tc.querySelector('#tax-enabled')?.addEventListener('change', (e) => {\n const container = tc.querySelector('#tax-rate-container');\n if (container) {\n container.style.display = e.target.checked ? 'flex' : 'none';\n }\n });\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Job Types Management Event Listeners ----\n const addJobTypeBtn = tc.querySelector('#add-job-type-btn');\n const newJobTypeInput = tc.querySelector('#new-job-type-input');\n const jobTypesList = tc.querySelector('#job-types-list');\n\n function updateJobTypesUI() {\n jobTypesList.innerHTML = currentJobTypes.map(type => `\n <span class=\"tag-pill tag-pill-active\" style=\"display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:100px; font-weight:500; font-size:13px; background:var(--color-primary-light); color:var(--color-primary-dark)\">\n <span>${escapeHTML(type)}</span>\n <span class=\"material-icons-outlined delete-job-type-btn\" data-type=\"${escapeHTML(type)}\" style=\"font-size:16px; cursor:pointer; opacity:0.7; transition:opacity 0.2s\">close</span>\n </span>\n `).join('');\n }\n\n addJobTypeBtn?.addEventListener('click', () => {\n const val = newJobTypeInput.value.trim();\n if (!val) {\n showToast('Please enter a job type name', 'warning');\n return;\n }\n if (currentJobTypes.includes(val)) {\n showToast('This job type already exists', 'warning');\n return;\n }\n currentJobTypes.push(val);\n newJobTypeInput.value = '';\n updateJobTypesUI();\n });\n\n tc.querySelector('#job-types-card')?.addEventListener('click', (e) => {\n const btn = e.target.closest('.delete-job-type-btn');\n if (btn) {\n const typeToDelete = btn.dataset.type;\n currentJobTypes = currentJobTypes.filter(t => t !== typeToDelete);\n updateJobTypesUI();\n }\n });\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#save-tax-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const taxEnabled = tc.querySelector('#tax-enabled').checked;\n const taxRate = parseFloat(tc.querySelector('#tax-rate').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.taxEnabled = taxEnabled;\n settings.taxRate = taxRate;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n settings.jobTypes = currentJobTypes;\n\n await store.saveSettings(settings);\n showToast('Financial and Rate settings saved successfully', 'success');\n renderContent();\n } catch (err) {\n console.error('Error saving tax and rate settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-portal-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n await store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving customer portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'portal_contractor') {\n const s = store.getSettings();\n const portalEnabled = s.enableContractorPortal !== false;\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Contractor Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"contractor-portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('contractor-portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Contractor Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any subcontractor attempting to load their portal token will see an access deactivated notice.</span>\n </div>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-contractor-settings\" data-tooltip=\"Save subcontractor portal access rules\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Contractor Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-contractor-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-contractor-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableContractorPortal = tc.querySelector('#contractor-portal-enable').checked;\n\n await store.saveSettings(settings);\n showToast('Contractor portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving contractor portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'system') {\n\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n const currentTheme = (currentUser && currentUser.id) ? (currentUser.theme || localStorage.getItem(`simpro_theme_${currentUser.id}`) || 'light') : 'light';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Left Column -->\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg)\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n </p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n ${currentUser.role === 'admin' ? `\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed a complete demonstration dataset\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed Demonstration Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n <button class=\"btn btn-danger\" id=\"btn-delete-company\" data-tooltip=\"Permanently delete the entire company profile and all data\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; background:var(--color-danger); border-color:var(--color-danger); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span> Delete Company Profile\n </button>\n\n ${isLocalMode ? `\n <div style=\"margin-top:16px; padding-top:12px; border-top:1px dashed rgba(220, 38, 38, 0.2)\">\n <h6 style=\"color:var(--color-warning); margin-bottom:8px; font-weight:600; display:flex; align-items:center; gap:4px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">warning</span> Irreversible Profile Conversions\n </h6>\n <p style=\"font-size:11px; color:var(--text-secondary); margin-bottom:12px; line-height:1.3;\">\n Convert your deployment type. These transitions are permanent and one-way.\n </p>\n ${localDeploymentType === 'single_user' ? `\n <button class=\"btn\" id=\"btn-convert-multiuser\" style=\"width:100%; justify-content:center; margin-bottom:12px; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">lan</span> Convert to Multi-User Local Network Sync\n </button>\n ` : ''}\n <button class=\"btn\" id=\"btn-convert-cloud-action\" style=\"width:100%; justify-content:center; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">cloud_upload</span> Convert to Cloud Sync\n </button>\n </div>\n ` : ''}\n </div>\n ` : '<div style=\"font-size:12.5px; color:var(--text-tertiary)\">Administrative actions are restricted to company administrators.</div>'}\n </div>\n </div>\n </div>\n\n <!-- Local Data Backup -->\n ${!isLocalMode && currentUser.role === 'admin' ? `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary)\">backup</span>\n <h4 style=\"margin:0;\">Local Data Backup</h4>\n </div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:12px;\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm); line-height:1.4; margin:0;\">\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n </p>\n <div id=\"backup-status-container\"></div>\n </div>\n </div>\n ` : ''}\n </div>\n\n <!-- Right Column -->\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">System Color Theme</label>\n <select class=\"form-select\" id=\"system-theme-select\" style=\"width:100%\">\n ${Object.entries(THEMES).map(([key, val]) => `\n <option value=\"${key}\" ${currentTheme === key ? 'selected' : ''}>${val.name}</option>\n `).join('')}\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Select a color profile and visual background effect for your user interface.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', async (e) => {\n const select = e.target;\n select.disabled = true;\n try {\n const settings = store.getSettings();\n settings.tooltipPreference = select.value;\n await store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved successfully', 'success');\n } catch (err) {\n console.error('Error saving tooltip preference:', err);\n showToast('Failed to save preferences: ' + (err.message || err), 'error');\n } finally {\n select.disabled = false;\n }\n });\n\n tc.querySelector('#system-theme-select')?.addEventListener('change', (e) => {\n applyTheme(e.target.value, true);\n showToast('System theme updated successfully', 'success');\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a complete demonstration dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load a complete, realistic demonstration dataset representing an active trade business (5 customers, 5 quotes/jobs per customer, 5 assets per customer, 5 materials, 5 users/timesheets, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for testing and walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed Demonstration Data\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Demo Data\",\n className: \"btn-primary\",\n onClick: async (close) => {\n close();\n showToast('Seeding demonstration data...', 'info');\n await seedMinimalData();\n showToast('Demonstration data seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-delete-company')?.addEventListener('click', () => {\n // Modal Warning 1 of 2\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">warning</span>\n <span>You are about to permanently delete the entire company profile for <strong>${escapeHTML(store.getSettings().name || '')}</strong> and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).</span>\n </div>\n <p style=\"font-size:12.5px; color:var(--text-secondary); margin-bottom:12px\">\n This action is final, irreversible, and cannot be undone under any circumstances.\n </p>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Do you want to proceed to password confirmation?</p>\n `;\n\n showModal({\n title: \"Delete Company Profile - Step 1 of 2\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (closeModal) => closeModal()\n },\n {\n label: \"Proceed to Confirm\",\n className: \"btn-danger\",\n onClick: (closeModal) => {\n closeModal();\n \n // Modal Warning 2 of 2\n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <form id=\"delete-company-confirm-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <p style=\"margin-bottom:8px; font-weight:600; color:var(--color-danger)\">FINAL CONFIRMATION (2 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">gavel</span>\n <span>To authorize the permanent destruction of this company, you must enter the Administrator password.</span>\n </div>\n\n <div class=\"form-group\" style=\"margin-top:12px;\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Password</label>\n <input class=\"form-input\" type=\"password\" id=\"delete-confirm-password\" required placeholder=\"Enter admin password to proceed\" />\n </div>\n\n <div id=\"delete-confirm-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"delete-confirm-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-delete-abort\">Abort Deletion</button>\n <button type=\"submit\" class=\"btn btn-danger\" id=\"btn-delete-confirm-submit\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span>\n <span>Permanently Delete Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close: close2 } = showModal({\n title: \"⚠️ Confirm Company Deletion - Step 2 of 2\",\n content: content2,\n size: \"modal-md\"\n });\n\n content2.querySelector('#btn-delete-abort').addEventListener('click', close2);\n\n const form2 = content2.querySelector('#delete-company-confirm-form');\n form2.addEventListener('submit', async (ev) => {\n ev.preventDefault();\n\n const errorEl = content2.querySelector('#delete-confirm-error');\n const errorTextEl = content2.querySelector('#delete-confirm-error-text');\n const submitBtn = content2.querySelector('#btn-delete-confirm-submit');\n const abortBtn = content2.querySelector('#btn-delete-abort');\n const passwordInput = content2.querySelector('#delete-confirm-password').value;\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n abortBtn.disabled = true;\n const origSubmitText = submitBtn.innerHTML;\n submitBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Deleting...';\n\n try {\n let passwordVerified = false;\n\n if (isLocalMode) {\n if (localDeploymentType === 'single_user') {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acct = storedAccounts.find(a => a.id === activeAccountId);\n if (acct && acct.hasPassword) {\n const hashedInput = await hashPassword(passwordInput);\n if (hashedInput === acct.passwordHash) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n // No password set\n passwordVerified = true;\n }\n } else {\n // local_multiuser\n const techs = store.getAll('technicians') || [];\n const currentTech = techs.find(t => t.id === currentUser.id);\n const expectedPassword = currentTech ? currentTech.password : '123456';\n if (passwordInput === expectedPassword) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n }\n } else {\n // Cloud mode\n const { supabase } = await import('../utils/supabase.js');\n const { data: { user } } = await supabase.auth.getUser();\n if (user && user.email) {\n const { error } = await supabase.auth.signInWithPassword({\n email: user.email,\n password: passwordInput\n });\n if (!error) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n throw new Error('Could not retrieve logged-in cloud user credentials.');\n }\n }\n\n if (passwordVerified) {\n // 1. If cloud mode, delete the company row in Supabase\n if (!isLocalMode) {\n const { supabase } = await import('../utils/supabase.js');\n const { error: deleteErr } = await supabase.from('companies').delete().eq('id', store.companyId);\n if (deleteErr) {\n throw new Error('Failed to delete cloud company: ' + deleteErr.message);\n }\n }\n\n // 2. Trigger store.clearAll()\n store.clearAll();\n\n // 3. Clear local account launcher keys if local mode\n if (isLocalMode) {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const updatedAccounts = storedAccounts.filter(a => a.id !== activeAccountId);\n await storageSet('relay_accounts', updatedAccounts);\n }\n }\n\n // 4. Remove session user & active account\n localStorage.removeItem('currentUser');\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Company profile deleted successfully.', 'success');\n close2();\n\n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n } catch (err) {\n console.error('Company deletion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during verification.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n abortBtn.disabled = false;\n submitBtn.innerHTML = origSubmitText;\n }\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-convert-multiuser')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n\n const techs = store.getAll('technicians') || [];\n const companyId = store.companyId || '';\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const existingAdmin = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n const currentTechName = existingAdmin ? existingAdmin.name : (currentUser.name && currentUser.name !== 'Local Admin' ? currentUser.name : '');\n const currentTechUsernameOrEmail = existingAdmin ? (existingAdmin.email || existingAdmin.username || '') : '';\n\n content.innerHTML = `\n <form id=\"convert-multiuser-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Multi-User Local Network Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a single-user Local Admin profile.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"multiuser-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"e.g. Apex Power Services\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"multiuser-admin-name\" required value=\"${escapeHTML(currentTechName)}\" placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Email Address (Required)</label>\n <input class=\"form-input\" type=\"email\" id=\"multiuser-admin-email\" required value=\"${escapeHTML(currentTechUsernameOrEmail)}\" placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Admin Password</label>\n <input class=\"form-input\" type=\"password\" id=\"multiuser-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"multiuser-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"multiuser-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-multiuser-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-multiuser-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">lan</span>\n <span>Convert Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: \"Convert to Multi-User Local Sync\",\n content: content,\n size: \"modal-md\"\n });\n\n content.querySelector('#btn-multiuser-cancel').addEventListener('click', close);\n\n const form = content.querySelector('#convert-multiuser-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n const errorEl = content.querySelector('#multiuser-error');\n const errorTextEl = content.querySelector('#multiuser-error-text');\n const submitBtn = content.querySelector('#btn-multiuser-submit');\n const cancelBtn = content.querySelector('#btn-multiuser-cancel');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n\n const businessName = content.querySelector('#multiuser-company-name').value.trim();\n const adminName = content.querySelector('#multiuser-admin-name').value.trim();\n const adminEmail = content.querySelector('#multiuser-admin-email').value.trim();\n const adminPassword = content.querySelector('#multiuser-admin-password').value;\n\n try {\n if (!businessName || !adminName || !adminEmail || !adminPassword) {\n throw new Error('All fields are required.');\n }\n if (adminPassword.length < 6) {\n throw new Error('Password must be at least 6 characters.');\n }\n\n // 1. Update settings\n const settings = store.getSettings();\n settings.name = businessName;\n settings.localDeploymentType = 'multi_user';\n await store.saveSettings(settings);\n\n // 2. Update launcher accounts businessName\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acctIndex = storedAccounts.findIndex(a => a.id === activeAccountId);\n if (acctIndex !== -1) {\n storedAccounts[acctIndex].businessName = businessName;\n await storageSet('relay_accounts', storedAccounts);\n }\n }\n\n // 3. Create or update admin user in technicians\n const companyId = store.companyId;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const techs = store.getAll('technicians') || [];\n let adminTech = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n if (!adminTech) {\n adminTech = {\n id: `${companyId}_tech_admin_created`,\n role: 'Admin',\n color: '#FF5C00',\n userTypeId: adminTypeId,\n payRate: 100.00,\n phone: ''\n };\n techs.push(adminTech);\n }\n\n adminTech.name = adminName;\n adminTech.username = adminEmail.split('@')[0];\n adminTech.email = adminEmail;\n adminTech.password = adminPassword;\n\n store.save('technicians', techs);\n\n // 4. Update currentUser session in localStorage\n const localUser = {\n id: adminTech.id,\n companyId: companyId,\n name: adminTech.name,\n role: 'admin',\n userTypeName: 'Admin',\n userTypeId: adminTech.userTypeId,\n color: adminTech.color || '#FF5C00',\n theme: 'light'\n };\n localStorage.setItem('currentUser', JSON.stringify(localUser));\n\n showToast('Converted to Multi-User Local Network Sync. Reloading...', 'success');\n close();\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n setTimeout(() => window.location.reload(), 1200);\n } catch (err) {\n console.error('Conversion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during conversion.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n }\n });\n });\n\n tc.querySelector('#btn-convert-cloud-action')?.addEventListener('click', () => {\n openMigrationModal();\n });\n\n const renderBackupStatus = () => {\n const bsc = tc.querySelector('#backup-status-container');\n if (!bsc) return;\n\n const hasHandle = !!store.backupDirHandle;\n const isPermissionGranted = store.backupDirPermissionGranted;\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const formattedLastBackup = lastBackup ? new Date(lastBackup).toLocaleString() : 'Never';\n const isSupported = typeof window !== 'undefined' && window.showDirectoryPicker;\n\n if (!isSupported) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-danger); line-height:1.4;\">\n <strong style=\"display:block; margin-bottom:4px;\">Browser Local Folder Access Unsupported</strong>\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n </div>\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:12px; border-radius:6px; margin-bottom:12px; color:var(--text-secondary); display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary);\">folder_off</span>\n <div style=\"font-size:12.5px;\">No backup folder configured.</div>\n </div>\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">folder</span> Choose Backup Folder...\n </button>\n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-warning); line-height:1.4; margin-bottom:12px;\">\n <strong>Permission Required</strong><br/>\n Access permission to <strong>${escapeHTML(store.backupDirHandle.name)}</strong> has expired. Please re-authorize.\n </div>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-warning\" id=\"btn-backup-auth\" style=\"flex:1; justify-content:center;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize & Backup\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n `;\n } else {\n bsc.innerHTML = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-success); line-height:1.4; margin-bottom:12px;\">\n <strong>Backup Configured</strong><br/>\n Folder: <strong>${escapeHTML(store.backupDirHandle.name)}</strong><br/>\n Last Backup: <strong>${formattedLastBackup}</strong>\n </div>\n <div style=\"display:flex; flex-direction:column; gap:8px;\">\n <button class=\"btn btn-primary\" id=\"btn-backup-now\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">backup</span> Backup Now\n </button>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"flex:1; justify-content:center; border:1px solid var(--border-color);\">\n <span class=\"material-icons-outlined\">folder</span> Change Folder...\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n </div>\n `;\n }\n\n // Attach event listeners for backup buttons inside container\n bsc.querySelector('#btn-backup-pick')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setBackupDirectory(handle);\n showToast('Backup directory selected successfully.', 'success');\n showToast('Running initial backup...', 'info');\n await store.backupToFolder(handle);\n showToast('Backup completed successfully!', 'success');\n renderBackupStatus();\n } catch (err) {\n console.error('Failed to select backup directory:', err);\n if (err.name !== 'AbortError') {\n showToast('Failed to configure backup: ' + err.message, 'error');\n }\n }\n });\n\n bsc.querySelector('#btn-backup-auth')?.addEventListener('click', async () => {\n const granted = await store.verifyBackupDirPermission(true);\n if (granted) {\n showToast('Permission re-authorized. Backing up...', 'info');\n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n showToast('Backup failed: ' + err.message, 'error');\n }\n } else {\n showToast('Failed to acquire write permission.', 'error');\n }\n renderBackupStatus();\n });\n\n bsc.querySelector('#btn-backup-now')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Backing up...';\n \n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Backup failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n renderBackupStatus();\n }\n });\n\n bsc.querySelector('#btn-backup-disconnect')?.addEventListener('click', async () => {\n await store.setBackupDirectory(null);\n showToast('Backup folder disconnected.', 'info');\n renderBackupStatus();\n });\n };\n\n if (!isLocalMode && currentUser.role === 'admin') {\n renderBackupStatus();\n }\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', username: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Username</label>\n <input class=\"form-input\" id=\"u-username\" value=\"${t.username || (t.email ? t.email.split('@')[0] : '')}\" ${editId ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''} placeholder=\"e.g. joshua\" />\n ${!editId ? `\n <div style=\"font-size: 11px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500;\">\n Company login code is: <strong style=\"color: var(--color-primary)\">${companySlug}</strong>. User will log in with <strong style=\"color: var(--color-primary)\">username@${companySlug}</strong>\n </div>\n ` : ''}\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">${editId ? 'Reset Password (leave blank to keep current)' : 'Temporary Password (assigned for worker login)'}</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"${editId ? '••••••••' : 'Min. 6 characters'}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\" ${(t.userTypeId === 'ut_admin' || (t.userTypeId && t.userTypeId.endsWith('_ut_admin'))) ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''}>\n <option value=\"\">-- Select --</option>\n ${userTypes\n .filter(ut => (!ut.id.endsWith('_ut_admin') && ut.id !== 'ut_admin') || t.userTypeId === ut.id)\n .map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value.trim();\n const username = document.getElementById('u-username').value.trim();\n const role = document.getElementById('u-role').value.trim();\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = document.getElementById('u-password')?.value || '';\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!username) { showToast('Username required', 'error'); return; }\n if (username.includes('@')) { showToast('Username cannot contain @ symbol', 'error'); return; }\n \n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (password && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n const updates = { name, username, role, userTypeId, color, payRate };\n if (password) {\n updates.password = password;\n }\n\n if (editId) {\n await store.update('technicians', editId, updates);\n } else {\n await store.create('technicians', updates);\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = async () => {\n const btn = tc.querySelector('#btn-save-materials');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n await store.saveSettings(updatedSettings);\n showToast('Material settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving material settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderPaymentsTab(tc) {\n const settings = store.getSettings() || {};\n const pay = settings.payments || {};\n const isCloud = !!(store.companyId && !String(store.companyId).startsWith('acct_'));\n\n if (!isCloud) {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:760px\">\n <div class=\"card-header\"><h4>Online Payments (Stripe)</h4></div>\n <div class=\"card-body\">\n <p style=\"color:var(--text-secondary);\">Online card payments are a cloud feature. Upgrade to a cloud account to let customers pay invoices online by card.</p>\n </div>\n </div>`;\n return;\n }\n\n const enabledFor = pay.enabledFor || {};\n const currencies = ['AUD', 'USD', 'NZD', 'GBP', 'EUR'];\n const cur = (pay.currency || 'AUD').toUpperCase();\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:820px\">\n <div class=\"card-header\"><h4>Online Payments (Stripe)</h4></div>\n <div class=\"card-body\">\n <p style=\"color:var(--text-secondary);margin-top:0;\">\n Let customers pay invoices by card via a secure Stripe checkout link. Generate a link from any sent invoice; it's marked Paid automatically once payment clears.\n </p>\n\n <div style=\"background:var(--content-bg);border:1px solid var(--border-color);border-radius:8px;padding:14px 16px;margin:14px 0;\">\n <div style=\"font-weight:600;font-size:13px;margin-bottom:8px;\">One-time setup</div>\n <ol style=\"margin:0;padding-left:18px;font-size:12px;color:var(--text-secondary);line-height:1.7;\">\n <li>Create a <strong>Stripe</strong> account and copy your <strong>Secret key</strong>.</li>\n <li>In Supabase → Edge Functions → Secrets, add <code>STRIPE_SECRET_KEY</code> and <code>STRIPE_WEBHOOK_SECRET</code>.</li>\n <li>Deploy the <code>relay-create-payment</code> and <code>relay-stripe-webhook</code> functions.</li>\n <li>In Stripe → Developers → Webhooks, add an endpoint pointing at <code>relay-stripe-webhook</code> and subscribe it to <code>checkout.session.completed</code>.</li>\n </ol>\n </div>\n\n <div class=\"form-group\" style=\"display:flex;align-items:center;gap:10px;\">\n <label class=\"switch\" style=\"margin:0;\">\n <input type=\"checkbox\" id=\"pay-connected\" ${pay.connected ? 'checked' : ''} />\n </label>\n <div>\n <div style=\"font-weight:600;font-size:13px;\">Payments configured &amp; live</div>\n <div style=\"font-size:11px;color:var(--text-tertiary);\">Tick once the setup above is done. Controls whether the Pay Link button appears on invoices.</div>\n </div>\n </div>\n\n <div class=\"form-group\" style=\"max-width:220px;\">\n <label class=\"form-label\">Currency</label>\n <select class=\"form-input\" id=\"pay-currency\">\n ${currencies.map(c => `<option value=\"${c}\" ${c === cur ? 'selected' : ''}>${c}</option>`).join('')}\n </select>\n </div>\n\n <div class=\"form-group\" style=\"display:flex;align-items:center;gap:10px;\">\n <input type=\"checkbox\" id=\"pay-enable-invoice\" style=\"width:16px;height:16px;\" ${enabledFor.invoice !== false ? 'checked' : ''} />\n <label for=\"pay-enable-invoice\" style=\"margin:0;font-size:13px;\">Show a \"Pay Link\" action on sent invoices</label>\n </div>\n\n <div style=\"margin-top:16px;\">\n <button class=\"btn btn-primary\" id=\"pay-save\"><span class=\"material-icons-outlined\">save</span> Save Payment Settings</button>\n </div>\n </div>\n </div>`;\n\n tc.querySelector('#pay-save')?.addEventListener('click', async () => {\n try {\n const s = store.getSettings() || {};\n s.payments = {\n ...(s.payments || {}),\n connected: tc.querySelector('#pay-connected').checked,\n currency: tc.querySelector('#pay-currency').value,\n enabledFor: {\n ...((s.payments || {}).enabledFor || {}),\n invoice: tc.querySelector('#pay-enable-invoice').checked,\n },\n };\n await store.saveSettings(s);\n showToast('Payment settings saved', 'success');\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n } catch (err) {\n console.error('Error saving payment settings:', err);\n showToast('Could not save payment settings', 'error');\n }\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-00023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-00129',\n originalQuoteNumber: 'Q-00412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' },\n ballet: { name: 'Ballet Pointe', preset: 'ballet', accentColor: '#e58799', headerBg: '#5c2c36', accentTint: '#fff5f6', fontFamily: 'serif' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-company-name\" style=\"width:16px; height:16px\" ${dt.hideCompanyName ? 'checked' : ''} />\n <label for=\"theme-hide-company-name\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide company name text (use logo only)</label>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Variation</label>\n <select class=\"form-select\" id=\"theme-logo-source\" style=\"width:100%\">\n <option value=\"large\" ${dt.logoSource === 'large' ? 'selected' : ''}>Standard Logo (Large)</option>\n <option value=\"small\" ${dt.logoSource === 'small' ? 'selected' : ''}>Small / Shrunk Logo (Small)</option>\n </select>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:8px 0\"></div>\n <div class=\"form-group ${isLocalMode ? 'disabled-local' : ''}\" ${isLocalMode ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''} style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-brand-logo\" style=\"width:16px; height:16px\" ${dt.hideBrandLogo ? 'checked' : ''} ${isLocalMode ? 'disabled' : ''} />\n <label for=\"theme-hide-brand-logo\" class=\"form-label\" style=\"margin:0; cursor:${isLocalMode ? 'not-allowed' : 'pointer'}\">Remove Relay-dispatch brand logo from document footers</label>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n <div class=\"form-row\" style=\"display:flex; gap:12px\">\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Invoice Prefix</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoicePrefix\" value=\"${escapeHTML(dt.invoicePrefix || 'INV-')}\" placeholder=\"INV-\" />\n </div>\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Invoice Starting Number</label>\n <input type=\"number\" class=\"form-input text-copy-input\" data-prop=\"invoiceStartingNumber\" value=\"${dt.invoiceStartingNumber !== undefined ? dt.invoiceStartingNumber : 1}\" min=\"1\" placeholder=\"1\" />\n </div>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n <div class=\"form-row\" style=\"display:flex; gap:12px\">\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Quote Prefix</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quotePrefix\" value=\"${escapeHTML(dt.quotePrefix || 'Q-')}\" placeholder=\"Q-\" />\n </div>\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Quote Starting Number</label>\n <input type=\"number\" class=\"form-input text-copy-input\" data-prop=\"quoteStartingNumber\" value=\"${dt.quoteStartingNumber !== undefined ? dt.quoteStartingNumber : 1}\" min=\"1\" placeholder=\"1\" />\n </div>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Hide Company Name toggle\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) {\n hideCompanyNameChk.addEventListener('change', () => {\n dt.hideCompanyName = hideCompanyNameChk.checked;\n updatePreview();\n });\n }\n\n // Logo Variation toggle\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) {\n logoSourceSelect.addEventListener('change', (e) => {\n dt.logoSource = e.target.value;\n updatePreview();\n });\n }\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Hide Brand Logo toggle\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) {\n hideBrandLogoChk.addEventListener('change', () => {\n dt.hideBrandLogo = hideBrandLogoChk.checked;\n updatePreview();\n });\n }\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-theme-save');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n await store.saveSettings(freshSettings);\n showToast('Document customization settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving theme settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n hideCompanyName: false,\n hideBrandLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) hideCompanyNameChk.checked = !!dt.hideCompanyName;\n\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) logoSourceSelect.value = dt.logoSource || 'large';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePrefixInp = tc.querySelector('[data-prop=\"invoicePrefix\"]');\n if (invoicePrefixInp) invoicePrefixInp.value = dt.invoicePrefix || '';\n\n const invoiceStartingNumberInp = tc.querySelector('[data-prop=\"invoiceStartingNumber\"]');\n if (invoiceStartingNumberInp) invoiceStartingNumberInp.value = dt.invoiceStartingNumber !== undefined ? dt.invoiceStartingNumber : 1;\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const quotePrefixInp = tc.querySelector('[data-prop=\"quotePrefix\"]');\n if (quotePrefixInp) quotePrefixInp.value = dt.quotePrefix || '';\n\n const quoteStartingNumberInp = tc.querySelector('[data-prop=\"quoteStartingNumber\"]');\n if (quoteStartingNumberInp) quoteStartingNumberInp.value = dt.quoteStartingNumber !== undefined ? dt.quoteStartingNumber : 1;\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) hideBrandLogoChk.checked = !!dt.hideBrandLogo;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n const logoUrl = dt.logoSource === 'small' ? (settings.logoSmall || settings.logo) : (settings.logo || settings.logoSmall);\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.src = logoUrl || '';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // Company Name visibility\n const companyNameEl = frameDoc.querySelector('.pdf-company-name');\n if (companyNameEl) {\n companyNameEl.style.display = dt.hideCompanyName ? 'none' : 'block';\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n\n const footerBrandingEl = frameDoc.querySelector('.pdf-footer-branding');\n if (footerBrandingEl) {\n const hideBrandLogo = isLocalMode ? false : !!dt.hideBrandLogo;\n footerBrandingEl.style.display = hideBrandLogo ? 'none' : 'flex';\n }\n }\n\n renderMarkup();\n }\n\n function renderFolderSyncTab(tc) {\n const isSupported = typeof window !== 'undefined' && (\n (window.indexedDB && window.showDirectoryPicker) ||\n (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem)\n );\n\n const isCapacitor = typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem;\n\n const activeAccountId = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n const isLocalCompany = !!activeAccountId;\n\n function render() {\n console.log('[DEBUG Settings] render: isLocalCompany =', isLocalCompany, 'store.dirHandle =', store.dirHandle, 'permissionGranted =', store.folderSyncPermissionGranted);\n const isEnabled = isLocalCompany ? true : store.folderSyncEnabled;\n const isPermissionGranted = store.folderSyncPermissionGranted;\n const hasHandle = !!store.dirHandle;\n\n let statusHtml = '';\n if (!isSupported) {\n statusHtml = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:16px; border-radius:4px; color:var(--color-danger); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:8px; font-weight:600; margin-bottom:4px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Browser Directory Access Unsupported</span>\n </div>\n <p style=\"font-size:var(--font-size-sm); margin:0; line-height:1.4;\">\n Your current browser does not support local folder access. To enable direct folder synchronization, please run this app in a Chromium-based browser (Chrome, Edge, Opera, or as a compiled native app). High-capacity IndexedDB storage remains active.\n </p>\n </div>\n `;\n } else if (!isEnabled) {\n statusHtml = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:16px; border-radius:6px; color:var(--text-secondary); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px; color:var(--text-tertiary);\">folder_off</span>\n <div>\n <div style=\"font-weight:600; color:var(--text-primary); margin-bottom:2px;\">Folder Synchronization Inactive</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4;\">All data is currently stored in your browser's private IndexedDB database sandbox.</p>\n </div>\n </div>\n `;\n } else if (isCapacitor) {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">cloud_done</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Capacitor Direct Folder Sync Active</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Data is synchronizing directly to the application's native <strong>Documents/RelayDispatchData</strong> directory on your iPad/device.\n </p>\n </div>\n </div>\n `;\n } else if (!hasHandle) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">warning</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">No Directory Selected</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">Please select a directory folder on your computer to begin syncing.</p>\n </div>\n </div>\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:12px; margin-bottom:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">lock</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Access Permission Suspended</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n The browser requires re-authorization to read/write files in <strong>${escapeHTML(store.dirHandle.name)}</strong>.\n </p>\n </div>\n </div>\n <button class=\"btn btn-warning\" id=\"btn-reauthorize-dir\" style=\"margin-left:44px;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize Access\n </button>\n </div>\n `;\n } else {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">check_circle</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Folder Sync Active & Synchronized</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Active Folder: <strong>${escapeHTML(store.dirHandle.name)}</strong>. All data edits are writing dynamically.\n </p>\n </div>\n </div>\n `;\n }\n\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <!-- Folder Configuration -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Direct Directory Sync</h4></div>\n <div class=\"card-body\">\n ${statusHtml}\n\n <div class=\"form-group\" style=\"margin-bottom:var(--space-lg);\">\n <label class=\"form-label\" style=\"font-weight:600; margin-bottom:8px;\">Synchronization Toggle</label>\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\">\n <input type=\"checkbox\" id=\"toggle-folder-sync\" ${isEnabled ? 'checked' : ''} ${(!isSupported || isLocalCompany) ? 'disabled' : ''} style=\"width:20px; height:20px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\" />\n <div>\n <span style=\"font-weight:500;\">Enable Direct Local Folder Storage</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">\n ${isLocalCompany ? 'Mandatory for offline/local company profile storage.' : 'Saves all data records and attachments straight to your machine.'}\n </div>\n </div>\n </label>\n </div>\n\n ${isEnabled && isSupported && !isCapacitor ? `\n <div style=\"display:flex; gap:12px; flex-wrap:wrap; border-top:1px solid var(--border-color); padding-top:var(--space-lg);\">\n <button class=\"btn btn-secondary\" id=\"btn-pick-dir\">\n <span class=\"material-icons-outlined\">folder</span> Choose Sync Folder...\n </button>\n ${hasHandle && isPermissionGranted ? `\n <button class=\"btn btn-secondary\" id=\"btn-force-sync\" data-tooltip=\"Overwrite folder JSONs with current memory cache\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">sync</span> Sync Now\n </button>\n ${!isLocalCompany ? `\n <button class=\"btn btn-danger\" id=\"btn-disconnect-dir\">\n <span class=\"material-icons-outlined\">link_off</span> Disconnect Folder\n </button>\n ` : ''}\n ` : ''}\n </div>\n ` : ''}\n </div>\n </div>\n\n <!-- Instructions Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>How Self-Hosting Works</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p>\n By linking a local folder, you establish a <strong>serverless self-hosted data hub</strong>.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">storage</span>\n <span><strong>Readable Data:</strong> Your jobs, quotes, and company configurations are saved in <code>data/*.json</code> files.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">photo_library</span>\n <span><strong>Physical Attachments:</strong> Uploaded photos and PDF catalogs are saved as standard image/PDF files under the <code>documents/</code> subfolder.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">sync_alt</span>\n <span><strong>Cloud Backups:</strong> Select a folder synced to <strong>OneDrive, Google Drive, or Dropbox</strong> to automatically back up and sync your data to the cloud.</span>\n </div>\n </div>\n </div>\n </div>\n `;\n\n // Event Listeners\n const toggle = tc.querySelector('#toggle-folder-sync');\n toggle?.addEventListener('change', async (e) => {\n if (isLocalCompany) {\n e.preventDefault();\n toggle.checked = true;\n return;\n }\n\n if (isCapacitor) {\n await store.setLocalDirectory(e.target.checked ? {} : null);\n showToast(e.target.checked ? 'Capacitor folder sync activated' : 'Folder sync deactivated', 'success');\n render();\n return;\n }\n\n if (e.target.checked) {\n // Trigger directory picker\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory configured successfully', 'success');\n } catch (err) {\n console.error('Directory selection cancelled or failed:', err);\n toggle.checked = false;\n showToast('Folder selection cancelled', 'info');\n }\n } else {\n await store.setLocalDirectory(null);\n showToast('Direct directory synchronization disabled', 'info');\n }\n render();\n });\n\n tc.querySelector('#btn-pick-dir')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory updated successfully', 'success');\n render();\n } catch (err) {\n console.error(err);\n }\n });\n\n tc.querySelector('#btn-reauthorize-dir')?.addEventListener('click', async () => {\n const granted = await store.verifyDirPermission(true);\n if (granted) {\n showToast('Folder access re-authorized successfully', 'success');\n } else {\n showToast('Failed to acquire write permissions', 'error');\n }\n render();\n });\n\n tc.querySelector('#btn-disconnect-dir')?.addEventListener('click', async () => {\n await store.setLocalDirectory(null);\n showToast('Local folder disconnected', 'info');\n render();\n });\n\n tc.querySelector('#btn-force-sync')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n btn.disabled = true;\n showToast('Writing cache data to local directory...', 'info');\n try {\n const collections = Object.keys(store.cache);\n await Promise.all(collections.map(col => store.writeCollectionToFolder(col, store.cache[col])));\n showToast('Folder sync completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Folder sync failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n }\n });\n }\n\n render();\n }\n\n function renderApiKeysTab(tc) {\n const s = store.getSettings();\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const ai = s.ai || {\n enabled: false,\n apiKey: '',\n visionApiKey: '',\n useSameKey: true,\n endpoint: 'https://api.deepseek.com/chat/completions',\n model: 'deepseek-chat',\n visionEndpoint: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',\n visionModel: 'gemini-2.0-flash',\n systemPrompt: 'You are Deputy, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n };\n if (ai.useSameKey === undefined) ai.useSameKey = true;\n const maps = s.maps || { apiKey: '' };\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null') || { id: 'default' };\n const userId = currentUser.id || 'default';\n const factsheetKey = `relay_factsheet_${userId}`;\n const enabledKey = `relay_factsheet_enabled_${userId}`;\n const factsheetVal = localStorage.getItem(factsheetKey) || '';\n const memoryEnabled = localStorage.getItem(enabledKey) !== 'false';\n\n function render() {\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg);\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>AI Assistant Configuration</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n \n <div class=\"form-group\">\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:pointer;\">\n <input type=\"checkbox\" id=\"ai-enabled\" ${ai.enabled ? 'checked' : ''} style=\"width:20px; height:20px; cursor:pointer;\" />\n <div>\n <span style=\"font-weight:600;\">Enable AI Assistant</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">Replaces the basic rule-based Relay co-pilot with a smart conversational LLM.</div>\n </div>\n </label>\n </div>\n\n <div id=\"ai-fields\" style=\"display: ${ai.enabled ? 'flex' : 'none'}; flex-direction:column; gap:16px; border-top:1px solid var(--border-color); padding-top:16px;\">\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Text API Key</label>\n <input type=\"password\" class=\"form-input\" id=\"ai-apikey\" value=\"${ai.apiKey || ''}\" placeholder=\"${isLocalMode ? 'sk-...' : 'Optional — Defaults to secure cloud API key'}\" />\n </div>\n <div class=\"form-group\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <label class=\"form-label\" style=\"font-weight:600; margin-bottom:0;\">Vision API Key</label>\n <label style=\"display:flex; align-items:center; gap:6px; font-size:12px; cursor:pointer;\">\n <input type=\"checkbox\" id=\"ai-use-same-key\" ${ai.useSameKey ? 'checked' : ''} style=\"width:14px; height:14px;\" /> Use Text API Key\n </label>\n </div>\n <input type=\"password\" class=\"form-input\" id=\"ai-vision-apikey\" value=\"${ai.useSameKey ? (ai.apiKey || '') : (ai.visionApiKey || '')}\" placeholder=\"API Key for Vision Model\" style=\"margin-top:6px; ${ai.useSameKey ? 'opacity:0.5; pointer-events:none;' : ''}\" />\n </div>\n </div>\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:-8px;\">${isLocalMode ? 'Your API keys are stored locally in your browser/sync directory.' : 'Keys are optional. If blank, cloud operations will route securely through Edge Functions.'}</div>\n\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Text API Endpoint</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-endpoint\" value=\"${ai.endpoint || 'https://api.deepseek.com/chat/completions'}\" placeholder=\"https://api.deepseek.com/chat/completions\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Text Model Name</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-model\" value=\"${ai.model || 'deepseek-chat'}\" placeholder=\"deepseek-chat\" />\n </div>\n </div>\n\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Vision API Endpoint (Multimodal)</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-vision-endpoint\" value=\"${ai.visionEndpoint || 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions'}\" placeholder=\"https://api.openai.com/v1/chat/completions\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Vision Model Name</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-vision-model\" value=\"${ai.visionModel || 'gemini-2.0-flash'}\" placeholder=\"gpt-4o-mini\" />\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Base System Prompt</label>\n <textarea class=\"form-input\" id=\"ai-systemprompt\" rows=\"4\" style=\"font-family:inherit; resize:vertical;\">${ai.systemPrompt || ''}</textarea>\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.</div>\n </div>\n\n <div style=\"display:flex; gap:12px; align-items:center; margin-top:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-test-ai\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">bolt</span> Test Connection\n </button>\n <span id=\"test-status\" style=\"font-size:13px; font-weight:500;\"></span>\n </div>\n <div id=\"test-error-details\" style=\"display:none; margin-top:8px; font-size:12px; color:var(--color-danger); background:var(--color-danger-bg); border-left:3px solid var(--color-danger); padding:8px; border-radius:4px; line-height:1.4;\"></div>\n </div>\n\n <div style=\"border-top:1px solid var(--border-color); padding-top:16px; display:flex; justify-content:flex-end;\">\n <button class=\"btn btn-primary\" id=\"btn-save-ai\">Save AI Config</button>\n </div>\n\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Google Maps Configuration</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Maps API Key</label>\n <input type=\"password\" class=\"form-input\" id=\"maps-apikey\" value=\"${maps.apiKey || ''}\" placeholder=\"AIzaSy...\" />\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">Used for address autocomplete, distance calculations, and rendering maps. Get this from the Google Cloud Console.</div>\n </div>\n <div style=\"border-top:1px solid var(--border-color); padding-top:16px; display:flex; justify-content:flex-end;\">\n <button class=\"btn btn-primary\" id=\"btn-save-maps\">Save Maps Config</button>\n </div>\n </div>\n </div>\n\n </div>\n\n <!-- Cheapest Smart AI Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>Cheapest Smart AI Guide</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p style=\"margin:0;\">\n <strong>DeepSeek</strong> is one of the most cost-efficient and high-performance language models currently available, making it perfect for lightweight co-pilot tasks.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">key</span>\n <span><strong>API Key:</strong> Get your key from <a href=\"https://platform.deepseek.com/\" target=\"_blank\" style=\"color:var(--color-primary); text-decoration:underline;\">platform.deepseek.com</a>. New accounts typically receive free trial credits.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">security</span>\n <span><strong>CORS Restrictions:</strong> Due to browser security, direct API calls to DeepSeek may be blocked. To bypass this:\n <ul style=\"margin:4px 0 0 16px; padding:0; list-style:disc; font-size:12px;\">\n <li>Route via a CORS proxy (e.g. <code>https://cors-anywhere.herokuapp.com/</code> prefix).</li>\n <li>Connect via an aggregator like <strong>OpenRouter</strong> (API Endpoint: <code>https://openrouter.ai/api/v1/chat/completions</code>).</li>\n <li>Use a local LLM runner like <strong>Ollama</strong> (API Endpoint: <code>http://localhost:11434/v1/chat/completions</code>).</li>\n </ul>\n </span>\n </div>\n </div>\n </div>\n\n </div>\n `;\n\n // Toggle display of fields based on enabled checkbox\n const enabledCheckbox = tc.querySelector('#ai-enabled');\n const fieldsContainer = tc.querySelector('#ai-fields');\n enabledCheckbox.addEventListener('change', (e) => {\n fieldsContainer.style.display = e.target.checked ? 'flex' : 'none';\n });\n\n const useSameKeyCheckbox = tc.querySelector('#ai-use-same-key');\n const visionApiKeyInput = tc.querySelector('#ai-vision-apikey');\n const textApiKeyInput = tc.querySelector('#ai-apikey');\n if (useSameKeyCheckbox && visionApiKeyInput) {\n useSameKeyCheckbox.addEventListener('change', (e) => {\n if (e.target.checked) {\n visionApiKeyInput.style.opacity = '0.5';\n visionApiKeyInput.style.pointerEvents = 'none';\n if (textApiKeyInput) visionApiKeyInput.value = textApiKeyInput.value;\n } else {\n visionApiKeyInput.style.opacity = '1';\n visionApiKeyInput.style.pointerEvents = 'auto';\n visionApiKeyInput.focus();\n }\n });\n \n if (textApiKeyInput) {\n textApiKeyInput.addEventListener('input', (e) => {\n if (useSameKeyCheckbox.checked) {\n visionApiKeyInput.value = e.target.value;\n }\n });\n }\n }\n\n\n\n // Save handler\n tc.querySelector('#btn-save-ai').addEventListener('click', () => {\n const enabled = enabledCheckbox.checked;\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const useSameKey = tc.querySelector('#ai-use-same-key') ? tc.querySelector('#ai-use-same-key').checked : true;\n const visionApiKey = useSameKey ? apiKey : tc.querySelector('#ai-vision-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const visionEndpoint = tc.querySelector('#ai-vision-endpoint').value.trim();\n const visionModel = tc.querySelector('#ai-vision-model').value.trim();\n const systemPrompt = tc.querySelector('#ai-systemprompt').value.trim();\n\n if (isLocalMode && enabled && !apiKey) {\n showToast('Text API Key is required when enabling the AI assistant.', 'error');\n return;\n }\n\n const settings = store.getSettings();\n settings.ai = {\n enabled,\n apiKey,\n visionApiKey,\n useSameKey,\n endpoint,\n model,\n visionEndpoint,\n visionModel,\n systemPrompt\n };\n\n store.saveSettings(settings);\n showToast('AI settings saved successfully', 'success');\n });\n\n tc.querySelector('#btn-save-maps').addEventListener('click', () => {\n const apiKey = tc.querySelector('#maps-apikey').value.trim();\n const settings = store.getSettings();\n settings.maps = { apiKey };\n store.saveSettings(settings);\n showToast('Maps settings saved successfully', 'success');\n });\n\n // Test handler\n tc.querySelector('#btn-test-ai').addEventListener('click', async () => {\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const statusEl = tc.querySelector('#test-status');\n const errEl = tc.querySelector('#test-error-details');\n\n const inElectron = !!(window.electronAPI && window.electronAPI.callDeepSeek);\n const hasEnvKey = !!(import.meta.env.VITE_DEEPSEEK_API_KEY);\n\n if (!apiKey && !inElectron && !hasEnvKey) {\n showToast('Enter an API key to test.', 'error');\n return;\n }\n\n statusEl.style.color = 'var(--text-secondary)';\n statusEl.textContent = 'Testing connection...';\n errEl.style.display = 'none';\n\n const startTime = Date.now();\n try {\n if (inElectron) {\n const data = await window.electronAPI.callDeepSeek({\n messages: [{ role: 'user', content: 'respond only with the word OK' }],\n endpoint,\n model,\n apiKey // Pass typed key if any\n });\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success (via Electron IPC)! Latency: ${latency}ms (Reply: \"${reply}\")`;\n } else {\n const res = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY}`\n },\n body: JSON.stringify({\n model: model || 'deepseek-chat',\n messages: [\n { role: 'user', content: 'respond only with the word OK' }\n ],\n max_tokens: 5\n })\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`HTTP ${res.status}: ${body}`);\n }\n\n const data = await res.json();\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success! Latency: ${latency}ms (Reply: \"${reply}\")`;\n }\n } catch (err) {\n console.error('AI test connection failed:', err);\n statusEl.style.color = 'var(--color-danger)';\n statusEl.textContent = 'Test failed';\n errEl.style.display = 'block';\n\n let errMsg = err.message;\n if (err.name === 'TypeError' && err.message.includes('Failed to fetch')) {\n errMsg = 'CORS Blocked or Offline. The browser blocked this cross-origin request. Please use a CORS proxy, an API gateway like OpenRouter, or check your internet connection.';\n }\n errEl.textContent = errMsg;\n }\n });\n }\n\n render();\n }\n\n function renderCostCentersTab(tc) {\n const list = store.getAll('costCenters') || [];\n\n const renderTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:900px\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Cost Centers</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-cost-center\" style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span> Add Cost Center\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"padding-left:16px; width:120px\">Code</th>\n <th>Name</th>\n <th>Xero Mapping</th>\n <th style=\"width:120px\">Status</th>\n <th style=\"text-align:right; padding-right:16px; width:180px\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${list.length === 0 ? `\n <tr>\n <td colspan=\"5\" style=\"text-align:center; padding:32px 16px; color:var(--text-secondary)\">\n No Cost Centers configured. Click \"Add Cost Center\" to create one.\n </td>\n </tr>\n ` : list.map(cc => `\n <tr>\n <td style=\"padding-left:16px; font-weight:600\">${escapeHTML(cc.code)}</td>\n <td>${escapeHTML(cc.name)}</td>\n <td>\n ${cc.xeroSalesAccountCode || cc.xeroTrackingOptionName ? `\n <div style=\"font-size:11px; line-height:1.4\">\n ${cc.xeroSalesAccountCode ? `<div><span class=\"text-tertiary\">Sales Code:</span> <strong>${escapeHTML(cc.xeroSalesAccountCode)}</strong></div>` : ''}\n ${cc.xeroExpenseAccountCode ? `<div><span class=\"text-tertiary\">Expense Code:</span> <strong>${escapeHTML(cc.xeroExpenseAccountCode)}</strong></div>` : ''}\n ${cc.xeroTrackingOptionName ? `<div><span class=\"text-tertiary\">Tracking:</span> <strong>${escapeHTML(cc.xeroTrackingCategoryName || 'Department')}:${escapeHTML(cc.xeroTrackingOptionName)}</strong></div>` : ''}\n </div>\n ` : '<span class=\"text-tertiary\" style=\"font-size:11px\">— Unmapped —</span>'}\n </td>\n <td>\n <span class=\"badge ${cc.active ? 'badge-success' : 'badge-neutral'}\">\n ${cc.active ? 'Active' : 'Inactive'}\n </span>\n </td>\n <td style=\"text-align:right; padding-right:16px\">\n <button class=\"btn btn-secondary btn-sm btn-edit-cc\" data-id=\"${cc.id}\" style=\"margin-right:6px\">Edit</button>\n <button class=\"btn btn-outline btn-sm btn-toggle-cc\" data-id=\"${cc.id}\">\n ${cc.active ? 'Disable' : 'Enable'}\n </button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-cost-center')?.addEventListener('click', () => openCostCenterModal());\n tc.querySelectorAll('.btn-edit-cc').forEach(btn => {\n btn.addEventListener('click', (e) => openCostCenterModal(e.target.dataset.id));\n });\n tc.querySelectorAll('.btn-toggle-cc').forEach(btn => {\n btn.addEventListener('click', async (e) => {\n const id = e.target.dataset.id;\n const cc = store.getById('costCenters', id);\n if (cc) {\n const newActive = !cc.active;\n await store.update('costCenters', id, { active: newActive });\n showToast(`Cost center ${cc.name} has been ${newActive ? 'enabled' : 'disabled'}.`, 'success');\n renderCostCentersTab(tc);\n }\n });\n });\n };\n\n renderTab();\n }\n\n function openCostCenterModal(editId = null) {\n let cc = editId ? store.getById('costCenters', editId) : { \n code: '', \n name: '', \n active: true,\n xeroSalesAccountCode: '',\n xeroExpenseAccountCode: '',\n xeroTrackingCategoryName: 'Department',\n xeroTrackingOptionName: ''\n };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\" style=\"margin-bottom:16px\">\n <label class=\"form-label\" style=\"display:block; margin-bottom:6px\">Cost Center Code</label>\n <input class=\"form-input\" id=\"cc-code\" value=\"${escapeHTML(cc.code)}\" placeholder=\"e.g. ELEC\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\" style=\"margin-bottom:16px\">\n <label class=\"form-label\" style=\"display:block; margin-bottom:6px\">Cost Center Name</label>\n <input class=\"form-input\" id=\"cc-name\" value=\"${escapeHTML(cc.name)}\" placeholder=\"e.g. Electrical Services\" style=\"width:100%\" />\n </div>\n \n <fieldset style=\"border: 1px solid var(--border-color); border-radius: 6px; padding: 12px; margin-bottom: 16px; background:var(--card-bg)\">\n <legend style=\"padding: 0 6px; font-size: 11px; font-weight: 600; color: var(--color-primary); display: flex; align-items: center; gap: 4px; margin: 0\">\n <span class=\"material-icons-outlined\" style=\"font-size:15px\">sync</span> Xero Integration (Optional)\n </legend>\n <div class=\"form-row\" style=\"margin-bottom:12px; display:grid; grid-template-columns:1fr 1fr; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Sales Account Code</label>\n <input class=\"form-input\" id=\"cc-xero-sales\" value=\"${escapeHTML(cc.xeroSalesAccountCode || '')}\" placeholder=\"e.g. 200\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Expense Account Code</label>\n <input class=\"form-input\" id=\"cc-xero-expense\" value=\"${escapeHTML(cc.xeroExpenseAccountCode || '')}\" placeholder=\"e.g. 300\" style=\"width:100%\" />\n </div>\n </div>\n <div class=\"form-row\" style=\"display:grid; grid-template-columns:1fr 1fr; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Tracking Category</label>\n <input class=\"form-input\" id=\"cc-xero-category\" value=\"${escapeHTML(cc.xeroTrackingCategoryName || 'Department')}\" placeholder=\"e.g. Department\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Tracking Option</label>\n <input class=\"form-input\" id=\"cc-xero-option\" value=\"${escapeHTML(cc.xeroTrackingOptionName || '')}\" placeholder=\"e.g. Electrical\" style=\"width:100%\" />\n </div>\n </div>\n </fieldset>\n\n <div class=\"form-group\">\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer\">\n <input type=\"checkbox\" id=\"cc-active\" ${cc.active ? 'checked' : ''} style=\"width:16px; height:16px; margin:0\" />\n <span>Active</span>\n </label>\n </div>\n `;\n\n showModal({\n title: editId ? 'Edit Cost Center' : 'Add Cost Center',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-cc', onClick: async (c) => {\n const code = document.getElementById('cc-code').value.trim().toUpperCase();\n const name = document.getElementById('cc-name').value.trim();\n const active = document.getElementById('cc-active').checked;\n const xeroSalesAccountCode = document.getElementById('cc-xero-sales').value.trim();\n const xeroExpenseAccountCode = document.getElementById('cc-xero-expense').value.trim();\n const xeroTrackingCategoryName = document.getElementById('cc-xero-category').value.trim();\n const xeroTrackingOptionName = document.getElementById('cc-xero-option').value.trim();\n\n if (!code) { showToast('Code required', 'error'); return; }\n if (!name) { showToast('Name required', 'error'); return; }\n\n try {\n const updates = { \n code, \n name, \n active,\n xeroSalesAccountCode: xeroSalesAccountCode || null,\n xeroExpenseAccountCode: xeroExpenseAccountCode || null,\n xeroTrackingCategoryName: xeroTrackingCategoryName || null,\n xeroTrackingOptionName: xeroTrackingOptionName || null\n };\n if (editId) {\n await store.update('costCenters', editId, updates);\n showToast('Cost Center updated successfully', 'success');\n } else {\n await store.create('costCenters', updates);\n showToast('Cost Center created successfully', 'success');\n }\n c();\n const tc = document.querySelector('#settings-content');\n renderCostCentersTab(tc);\n } catch (err) {\n console.error('Error saving cost center:', err);\n showToast('Failed to save cost center: ' + err.message, 'error');\n }\n }}\n ]\n });\n }\n\n function renderSuppliersSettings(tc) {\n const settings = store.getSettings();\n const categories = settings.supplierCategories || ['Electrical', 'Plumbing', 'HVAC', 'Fire Safety', 'Security', 'General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Supplier Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Define classifications/categories for your suppliers (e.g. Electrical, Plumbing, HVAC). These categories are used to group suppliers in the Suppliers directory.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"supplier-categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${escapeHTML(c)}\n <span class=\"material-icons-outlined btn-remove-supplier-cat\" data-name=\"${escapeHTML(c)}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-supplier-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-suppliers\" data-tooltip=\"Save supplier categories settings\" data-tooltip-pos=\"top\">Save Supplier Settings</button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-supplier-category').addEventListener('click', () => {\n const name = prompt('Enter supplier category name:');\n if (name) {\n const trimmed = name.trim();\n if (!trimmed) return;\n const exists = Array.from(tc.querySelectorAll('.btn-remove-supplier-cat')).some(span => span.dataset.name.toLowerCase() === trimmed.toLowerCase());\n if (exists) {\n showToast('Category already exists', 'error');\n return;\n }\n\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${escapeHTML(trimmed)}\n <span class=\"material-icons-outlined btn-remove-supplier-cat\" data-name=\"${escapeHTML(trimmed)}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#supplier-categories-container').insertBefore(btn, tc.querySelector('#btn-add-supplier-category'));\n btn.querySelector('.btn-remove-supplier-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-supplier-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-suppliers').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-suppliers');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const supplierCategories = Array.from(tc.querySelectorAll('.btn-remove-supplier-cat')).map(span => span.dataset.name);\n const updatedSettings = {\n ...settings,\n supplierCategories\n };\n await store.saveSettings(updatedSettings);\n showToast('Supplier settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving supplier settings:', err);\n showToast('Failed to save supplier settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n }\n"}, "040b9ce8c025398334b21048660f2d999383c8d0": {"t": 1785754176.6954205, "n": 280419, "h": "688614056dad6631", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData, seedData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\nimport { applyTheme, THEMES } from '../utils/theme.js';\nimport { storageGet, storageSet } from '../utils/tauriStore.js';\n\n// Compress uploaded images using Canvas to avoid huge Base64 data payloads\nfunction compressImage(dataUrl, maxWidth, maxHeight) {\n return new Promise((resolve) => {\n if (!dataUrl) {\n resolve(dataUrl);\n return;\n }\n // SVG vectors are already lightweight; do not compress them\n if (dataUrl.startsWith('data:image/svg+xml')) {\n resolve(dataUrl);\n return;\n }\n const img = new Image();\n img.onload = () => {\n let width = img.width;\n let height = img.height;\n\n if (width > height) {\n if (width > maxWidth) {\n height = Math.round((height * maxWidth) / width);\n width = maxWidth;\n }\n } else {\n if (height > maxHeight) {\n width = Math.round((width * maxHeight) / height);\n height = maxHeight;\n }\n }\n\n const canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n\n const ctx = canvas.getContext('2d');\n ctx.drawImage(img, 0, 0, width, height);\n\n const isPng = dataUrl.includes('image/png');\n const format = isPng ? 'image/png' : 'image/jpeg';\n const quality = isPng ? undefined : 0.85;\n\n resolve(canvas.toDataURL(format, quality));\n };\n img.onerror = () => {\n resolve(dataUrl);\n };\n img.src = dataUrl;\n });\n}\n\n// Helper to hash password using SHA-256 Web Crypto API\nasync function hashPassword(password) {\n const msgBuffer = new TextEncoder().encode(password);\n const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);\n const hashArray = Array.from(new Uint8Array(hashBuffer));\n return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');\n}\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n const localDeploymentType = settings.localDeploymentType || 'single_user';\n\n const isUsersDisabled = isLocalMode && localDeploymentType === 'single_user';\n const isPortalDisabled = isLocalMode;\n const isFolderSyncDisabled = !isLocalMode;\n\n if (isUsersDisabled && activeTab === 'users') {\n activeTab = 'company';\n }\n if (isPortalDisabled && activeTab === 'portal') {\n activeTab = 'company';\n }\n if (isPortalDisabled && activeTab === 'portal_contractor') {\n activeTab = 'company';\n }\n if (isFolderSyncDisabled && activeTab === 'folder_sync') {\n activeTab = 'company';\n }\n \n const isAIAssistantDisabled = isLocalMode;\n if (isAIAssistantDisabled && activeTab === 'ai_assistant') {\n activeTab = 'company';\n }\n\n function getCategoryForTab(tab) {\n if (['company', 'portal', 'portal_contractor', 'folder_sync', 'ai_assistant', 'system'].includes(tab)) return 'general';\n if (['templates_forms', 'invoices_quotes'].includes(tab)) return 'workflow';\n if (['users', 'suppliers'].includes(tab)) return 'people';\n if (['materials', 'cost_centers', 'tax'].includes(tab)) return 'resources';\n return 'general';\n }\n\n let activeCategory = getCategoryForTab(activeTab);\n\n const openMigrationModal = () => {\n const modalContent = document.createElement('div');\n modalContent.innerHTML = `\n <form id=\"convert-cloud-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Cloud Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a local/offline account. All data will be migrated to the secure cloud database.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"migrate-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"Company Name\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"migrate-admin-name\" required placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Phone Number</label>\n <input class=\"form-input\" id=\"migrate-admin-phone\" required placeholder=\"e.g. 0412 345 678\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Email Address (Username)</label>\n <input class=\"form-input\" type=\"email\" id=\"migrate-admin-email\" required placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Password</label>\n <input class=\"form-input\" type=\"password\" id=\"migrate-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"migration-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"migration-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-migrate-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-migrate-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" id=\"submit-icon\" style=\"font-size:18px;\">cloud_done</span>\n <span id=\"submit-text\">Register & Start Migration</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: 'Register & Migrate to Cloud',\n content: modalContent,\n size: 'modal-md'\n });\n\n modalContent.querySelector('#btn-migrate-cancel').addEventListener('click', close);\n\n const form = modalContent.querySelector('#convert-cloud-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n\n const errorEl = modalContent.querySelector('#migration-error');\n const errorTextEl = modalContent.querySelector('#migration-error-text');\n const submitBtn = modalContent.querySelector('#btn-migrate-submit');\n const cancelBtn = modalContent.querySelector('#btn-migrate-cancel');\n const submitText = modalContent.querySelector('#submit-text');\n const submitIcon = modalContent.querySelector('#submit-icon');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n submitText.textContent = 'Migrating to Cloud...';\n submitIcon.className = 'material-icons-outlined spinner';\n submitIcon.textContent = 'sync';\n submitIcon.style.animation = 'spin 1s linear infinite';\n\n const companyName = modalContent.querySelector('#migrate-company-name').value.trim();\n const adminName = modalContent.querySelector('#migrate-admin-name').value.trim();\n const adminPhone = modalContent.querySelector('#migrate-admin-phone').value.trim();\n const email = modalContent.querySelector('#migrate-admin-email').value.trim();\n const password = modalContent.querySelector('#migrate-admin-password').value;\n\n try {\n const settings = store.getSettings();\n settings.name = companyName;\n await store.saveSettings(settings);\n\n const { supabase } = await import('../utils/supabase.js');\n\n const { data: authData, error: authErr } = await supabase.auth.signUp({\n email,\n password,\n options: {\n data: {\n name: adminName,\n phone: adminPhone\n }\n }\n });\n if (authErr) throw authErr;\n\n if (!authData.user) {\n throw new Error('Verification required or signup was blocked. Check your email inbox.');\n }\n\n const { data: companyId, error: rpcError } = await supabase.rpc('create_company_and_admin', {\n user_id: authData.user.id,\n company_name: companyName,\n admin_name: adminName,\n admin_phone: adminPhone\n });\n if (rpcError) throw rpcError;\n\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n await store.migrateLocalToCloud(companyId, authData.user.id);\n\n if (activeAccountId) {\n const localAccountsKey = 'relay_accounts';\n let localAccounts = [];\n try {\n const stored = localStorage.getItem(localAccountsKey);\n if (stored) {\n localAccounts = JSON.parse(stored);\n }\n } catch (e) {\n console.error('Error reading local accounts:', e);\n }\n localAccounts = localAccounts.filter(a => a.id !== activeAccountId);\n localStorage.setItem(localAccountsKey, JSON.stringify(localAccounts));\n\n store.deleteLocalAccountData(activeAccountId);\n }\n\n const { data: profile, error: profileErr } = await supabase\n .from('profiles')\n .select('*')\n .eq('id', authData.user.id)\n .single();\n if (profileErr) throw profileErr;\n\n const user = {\n id: profile.id,\n companyId: profile.company_id,\n name: profile.name,\n role: profile.role,\n userTypeName: 'Admin',\n userTypeId: `${profile.company_id}_ut_admin`,\n color: profile.color || '#FF5C00',\n theme: 'light'\n };\n\n localStorage.setItem('currentUser', JSON.stringify(user));\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Migration completed successfully! Redirecting...', 'success');\n close();\n\n setTimeout(() => {\n window.location.hash = '#/';\n window.location.reload();\n }, 1500);\n\n } catch (err) {\n console.error('Migration failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during migration.';\n errorEl.style.display = 'flex';\n\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n submitText.textContent = 'Register & Start Migration';\n submitIcon.className = 'material-icons-outlined';\n submitIcon.textContent = 'cloud_done';\n submitIcon.style.animation = '';\n }\n });\n };\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n const categories = [\n {\n id: 'general',\n label: 'General',\n icon: 'settings',\n tabs: [\n { id: 'company', label: 'Company Profile' },\n { id: 'portal', label: 'Customer Portal', disabled: isPortalDisabled, tooltip: 'Requires Cloud Account' },\n { id: 'portal_contractor', label: 'Contractor Portal', disabled: isPortalDisabled, tooltip: 'Requires Cloud Account' },\n { id: 'folder_sync', label: 'Folder Sync', disabled: isFolderSyncDisabled, tooltip: 'Requires Local Folder Storage' },\n { id: 'api_keys', label: 'Integrations & APIs' },\n { id: 'system', label: 'System Options' }\n ]\n },\n {\n id: 'workflow',\n label: 'Workflow',\n icon: 'account_tree',\n tabs: [\n { id: 'templates_forms', label: 'Templates & Forms' },\n { id: 'invoices_quotes', label: 'Quotes & Invoices' }\n ]\n },\n {\n id: 'people',\n label: 'People',\n icon: 'groups',\n tabs: [\n { id: 'users', label: 'Users & Permissions', disabled: isUsersDisabled, tooltip: 'Requires Cloud Account or Local Server' },\n { id: 'suppliers', label: 'Suppliers Configuration' }\n ]\n },\n {\n id: 'resources',\n label: 'Resources',\n icon: 'widgets',\n tabs: [\n { id: 'materials', label: 'Materials & Kits' },\n { id: 'cost_centers', label: 'Cost Centers & Xero' },\n { id: 'tax', label: 'Tax & Labor Rates' }\n ]\n }\n ];\n\n function render() {\n const categoryTabsHtml = categories.map(cat => {\n const isActive = activeCategory === cat.id;\n return `\n <button class=\"settings-category-btn ${isActive ? 'active' : ''}\" data-category=\"${cat.id}\">\n <span class=\"material-icons-outlined\">${cat.icon}</span>\n <span>${cat.label}</span>\n </button>\n `;\n }).join('');\n\n const activeCatData = categories.find(cat => cat.id === activeCategory) || categories[0];\n const subTabsHtml = activeCatData.tabs.map(tab => {\n const isActive = activeTab === tab.id;\n const isDisabled = tab.disabled;\n const tooltipAttr = isDisabled ? `data-tooltip=\"${tab.tooltip}\" data-tooltip-pos=\"top\"` : '';\n return `\n <button class=\"settings-tab-btn ${isActive ? 'active' : ''} ${isDisabled ? 'disabled-local' : ''}\" data-tab=\"${tab.id}\" ${tooltipAttr}>\n ${tab.label}\n </button>\n `;\n }).join('');\n\n container.innerHTML = `\n <style>\n .settings-nav-categories {\n display: flex;\n gap: 8px;\n border-bottom: 2px solid var(--border-color);\n padding-bottom: 12px;\n margin-bottom: 20px;\n }\n .settings-category-btn {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 10px 20px;\n font-size: 15px;\n font-weight: 600;\n color: var(--text-secondary);\n background: transparent;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n .settings-category-btn .material-icons-outlined {\n font-size: 20px;\n transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n .settings-category-btn:hover {\n color: var(--text-primary);\n background: var(--bg-color);\n }\n .settings-category-btn:hover .material-icons-outlined {\n transform: scale(1.08);\n }\n .settings-category-btn.active {\n color: white;\n background: var(--color-primary);\n box-shadow: 0 4px 14px rgba(255, 92, 0, 0.25);\n transform: translateY(-1px);\n }\n \n .settings-nav-tabs {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-bottom: 24px;\n background: var(--bg-color);\n padding: 6px;\n border-radius: 10px;\n border: 1px solid var(--border-color);\n animation: subtabs-slide-in 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);\n }\n @keyframes subtabs-slide-in {\n from {\n opacity: 0;\n transform: translateX(-10px);\n }\n to {\n opacity: 1;\n transform: translateX(0);\n }\n }\n .settings-tab-btn {\n padding: 8px 16px;\n font-size: var(--font-size-sm);\n font-weight: 500;\n color: var(--text-secondary);\n background: transparent;\n border: none;\n border-radius: 20px;\n cursor: pointer;\n transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n .settings-tab-btn:hover:not(.disabled-local) {\n color: var(--text-primary);\n background: rgba(128, 128, 128, 0.08);\n }\n .settings-tab-btn.active {\n color: var(--color-primary);\n background: var(--content-bg);\n font-weight: 600;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);\n transform: scale(1.02);\n }\n \n #settings-content {\n animation: settings-fade-in 0.28s cubic-bezier(0.4, 0, 0.2, 1);\n }\n @keyframes settings-fade-in {\n from {\n opacity: 0;\n transform: translateY(6px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n }\n </style>\n\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"settings-nav-categories\">\n ${categoryTabsHtml}\n </div>\n\n <div class=\"settings-nav-tabs\">\n ${subTabsHtml}\n </div>\n\n <div id=\"settings-content\" style=\"padding-top:var(--space-sm)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.settings-category-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n const catId = btn.dataset.category;\n if (activeCategory === catId) return;\n\n activeCategory = catId;\n const catData = categories.find(c => c.id === catId);\n const firstActiveTab = catData.tabs.find(t => !t.disabled) || catData.tabs[0];\n activeTab = firstActiveTab.id;\n\n render();\n });\n });\n\n container.querySelectorAll('.settings-tab-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n if (btn.classList.contains('disabled-local')) return;\n activeTab = btn.dataset.tab;\n render();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'suppliers') {\n renderSuppliersSettings(tc);\n return;\n }\n\n if (activeTab === 'folder_sync') {\n renderFolderSyncTab(tc);\n return;\n }\n\n if (activeTab === 'api_keys') {\n renderApiKeysTab(tc);\n return;\n }\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'cost_centers') {\n renderCostCentersTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n\n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Standard Logo (Large) using Canvas to ~600x300 max bounding box\n compressImage(re.target.result, 600, 300).then((compressed) => {\n pendingLogo = compressed;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Small/Shrunk Logo using Canvas to ~200x100 max bounding box\n compressImage(re.target.result, 200, 100).then((compressed) => {\n pendingLogoSmall = compressed;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n\n\n tc.querySelector('#btn-save-company').addEventListener('click', async () => {\n const saveBtn = tc.querySelector('#btn-save-company');\n const originalHtml = saveBtn.innerHTML;\n saveBtn.disabled = true;\n saveBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving Changes...';\n\n try {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n await store.saveSettings(settings);\n showToast('Company information saved successfully to database', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n render();\n } catch (err) {\n console.error('Error saving company profile settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n saveBtn.disabled = false;\n saveBtn.innerHTML = originalHtml;\n }\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n const pendingResets = store.getAll('passwordResetRequests') || [];\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div style=\"background:rgba(59, 130, 246, 0.1); border-left:4px solid #3b82f6; padding:12px 16px; margin-bottom:var(--space-md); border-radius:4px; font-size:13px; color:#f8fafc; display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:#3b82f6; font-size:18px;\">info</span>\n <span>Your company login code is <strong style=\"color:#60a5fa\">${companySlug}</strong>. Technicians log in using <strong style=\"color:#60a5fa\">username@${companySlug}</strong>.</span>\n </span>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Username</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n ${ut?.id !== 'ut_admin' ? `\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n ` : ''}\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\">\n <h4 style=\"margin:0\">Pending Password Reset Requests</h4>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Username/Email</th>\n <th>Requested On</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:24px\">No pending reset requests</td></tr>' : ''}\n ${pendingResets.filter(r => r.status === 'pending').map(r => {\n const tech = techs.find(t => t.id === r.technician_id) || {};\n const requestedAt = r.requested_at ? new Date(r.requested_at).toLocaleString() : 'Unknown';\n return `\n <tr>\n <td class=\"font-medium\">${tech.name || 'Unknown'}</td>\n <td class=\"text-secondary\">${r.employee_id || tech.username || ''}</td>\n <td class=\"text-tertiary\">${requestedAt}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-approve-reset\" data-id=\"${r.id}\" data-tech-id=\"${tech.id}\">Approve &amp; Reset</button>\n <button class=\"btn btn-sm btn-ghost text-danger btn-deny-reset\" data-id=\"${r.id}\">Deny</button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n ${isLocalMode ? '' : `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `}\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => {\n openUserModal();\n });\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-approve-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const techId = e.currentTarget.dataset.techId;\n const t = store.getById('technicians', techId);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <p>Approve password reset request for <strong>${t.name}</strong>? Please enter their new password below:</p>\n <div class=\"form-group\" style=\"margin-top:12px\">\n <label class=\"form-label\">New Password</label>\n <input type=\"password\" id=\"admin-reset-pwd-input\" class=\"form-input\" placeholder=\"Min. 6 characters\" minlength=\"6\" autofocus />\n </div>\n `;\n showModal({\n title: 'Approve Password Reset',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reset Password', className: 'btn-primary', onClick: c => {\n const pwdInput = document.getElementById('admin-reset-pwd-input');\n const newPwd = pwdInput ? pwdInput.value : '';\n if (!newPwd || newPwd.length < 6) {\n showToast('Password must be at least 6 characters.', 'error');\n return;\n }\n \n // Update technician password\n store.update('technicians', techId, { password: newPwd });\n \n // Approve the request\n store.update('passwordResetRequests', id, { status: 'approved', updated_at: new Date().toISOString() });\n \n showToast(`Password updated for ${t.name}`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-deny-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n store.update('passwordResetRequests', id, { status: 'denied', updated_at: new Date().toISOString() });\n showToast('Password reset request denied', 'info');\n renderContent();\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n let currentJobTypes = settings.jobTypes ? [...settings.jobTypes] : ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'];\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg)\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax Rates</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\" style=\"display:flex; flex-direction:column; gap:8px\">\n <label class=\"form-label\" style=\"display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:0\">\n <input type=\"checkbox\" id=\"tax-enabled\" style=\"width:16px; height:16px; margin:0\" ${settings.taxEnabled !== false ? 'checked' : ''} />\n Enable GST / Sales Tax\n </label>\n <div style=\"display:${settings.taxEnabled !== false ? 'flex' : 'none'}; align-items:center; gap:8px; margin-top:4px\" id=\"tax-rate-container\">\n <input class=\"form-input\" id=\"tax-rate\" type=\"number\" value=\"${settings.taxRate !== undefined ? settings.taxRate : 10}\" style=\"width:100px\" min=\"0\" max=\"100\" step=\"0.1\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" id=\"job-types-card\">\n <div class=\"card-header\"><h4>Job Types</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:12px\">Define the types of jobs your team performs (e.g. Electrical, Plumbing). These appear in job forms and rate mapping.</p>\n <div id=\"job-types-list\" style=\"display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px;\">\n ${currentJobTypes.map(type => `\n <span class=\"tag-pill tag-pill-active\" style=\"display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:100px; font-weight:500; font-size:13px; background:var(--color-primary-light); color:var(--color-primary-dark)\">\n <span>${escapeHTML(type)}</span>\n <span class=\"material-icons-outlined delete-job-type-btn\" data-type=\"${escapeHTML(type)}\" style=\"font-size:16px; cursor:pointer; opacity:0.7; transition:opacity 0.2s\">close</span>\n </span>\n `).join('')}\n </div>\n <div style=\"display:flex; gap:8px;\">\n <input class=\"form-input\" id=\"new-job-type-input\" type=\"text\" placeholder=\"e.g. Renovation\" style=\"flex:1\" />\n <button class=\"btn btn-secondary\" id=\"add-job-type-btn\" style=\"display:flex; align-items:center; gap:4px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${escapeHTML(rate.name)}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${escapeHTML(rate.description || '')}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => {\n const active = applicable.includes(day);\n return `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${active ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'};background:${active ? 'var(--color-primary-light)' : 'transparent'};color:${active ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `}).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // Toggle tax rate container visibility\n tc.querySelector('#tax-enabled')?.addEventListener('change', (e) => {\n const container = tc.querySelector('#tax-rate-container');\n if (container) {\n container.style.display = e.target.checked ? 'flex' : 'none';\n }\n });\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Job Types Management Event Listeners ----\n const addJobTypeBtn = tc.querySelector('#add-job-type-btn');\n const newJobTypeInput = tc.querySelector('#new-job-type-input');\n const jobTypesList = tc.querySelector('#job-types-list');\n\n function updateJobTypesUI() {\n jobTypesList.innerHTML = currentJobTypes.map(type => `\n <span class=\"tag-pill tag-pill-active\" style=\"display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:100px; font-weight:500; font-size:13px; background:var(--color-primary-light); color:var(--color-primary-dark)\">\n <span>${escapeHTML(type)}</span>\n <span class=\"material-icons-outlined delete-job-type-btn\" data-type=\"${escapeHTML(type)}\" style=\"font-size:16px; cursor:pointer; opacity:0.7; transition:opacity 0.2s\">close</span>\n </span>\n `).join('');\n }\n\n addJobTypeBtn?.addEventListener('click', () => {\n const val = newJobTypeInput.value.trim();\n if (!val) {\n showToast('Please enter a job type name', 'warning');\n return;\n }\n if (currentJobTypes.includes(val)) {\n showToast('This job type already exists', 'warning');\n return;\n }\n currentJobTypes.push(val);\n newJobTypeInput.value = '';\n updateJobTypesUI();\n });\n\n tc.querySelector('#job-types-card')?.addEventListener('click', (e) => {\n const btn = e.target.closest('.delete-job-type-btn');\n if (btn) {\n const typeToDelete = btn.dataset.type;\n currentJobTypes = currentJobTypes.filter(t => t !== typeToDelete);\n updateJobTypesUI();\n }\n });\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#save-tax-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const taxEnabled = tc.querySelector('#tax-enabled').checked;\n const taxRate = parseFloat(tc.querySelector('#tax-rate').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.taxEnabled = taxEnabled;\n settings.taxRate = taxRate;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n settings.jobTypes = currentJobTypes;\n\n await store.saveSettings(settings);\n showToast('Financial and Rate settings saved successfully', 'success');\n renderContent();\n } catch (err) {\n console.error('Error saving tax and rate settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-portal-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n await store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving customer portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'portal_contractor') {\n const s = store.getSettings();\n const portalEnabled = s.enableContractorPortal !== false;\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Contractor Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"contractor-portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('contractor-portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Contractor Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any subcontractor attempting to load their portal token will see an access deactivated notice.</span>\n </div>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-contractor-settings\" data-tooltip=\"Save subcontractor portal access rules\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Contractor Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-contractor-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-contractor-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableContractorPortal = tc.querySelector('#contractor-portal-enable').checked;\n\n await store.saveSettings(settings);\n showToast('Contractor portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving contractor portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'system') {\n\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n const currentTheme = (currentUser && currentUser.id) ? (currentUser.theme || localStorage.getItem(`simpro_theme_${currentUser.id}`) || 'light') : 'light';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Left Column -->\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg)\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n </p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n ${currentUser.role === 'admin' ? `\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed a complete demonstration dataset\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed Demonstration Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n <button class=\"btn btn-danger\" id=\"btn-delete-company\" data-tooltip=\"Permanently delete the entire company profile and all data\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; background:var(--color-danger); border-color:var(--color-danger); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span> Delete Company Profile\n </button>\n\n ${isLocalMode ? `\n <div style=\"margin-top:16px; padding-top:12px; border-top:1px dashed rgba(220, 38, 38, 0.2)\">\n <h6 style=\"color:var(--color-warning); margin-bottom:8px; font-weight:600; display:flex; align-items:center; gap:4px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">warning</span> Irreversible Profile Conversions\n </h6>\n <p style=\"font-size:11px; color:var(--text-secondary); margin-bottom:12px; line-height:1.3;\">\n Convert your deployment type. These transitions are permanent and one-way.\n </p>\n ${localDeploymentType === 'single_user' ? `\n <button class=\"btn\" id=\"btn-convert-multiuser\" style=\"width:100%; justify-content:center; margin-bottom:12px; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">lan</span> Convert to Multi-User Local Network Sync\n </button>\n ` : ''}\n <button class=\"btn\" id=\"btn-convert-cloud-action\" style=\"width:100%; justify-content:center; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">cloud_upload</span> Convert to Cloud Sync\n </button>\n </div>\n ` : ''}\n </div>\n ` : '<div style=\"font-size:12.5px; color:var(--text-tertiary)\">Administrative actions are restricted to company administrators.</div>'}\n </div>\n </div>\n </div>\n\n <!-- Local Data Backup -->\n ${!isLocalMode && currentUser.role === 'admin' ? `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary)\">backup</span>\n <h4 style=\"margin:0;\">Local Data Backup</h4>\n </div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:12px;\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm); line-height:1.4; margin:0;\">\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n </p>\n <div id=\"backup-status-container\"></div>\n </div>\n </div>\n ` : ''}\n </div>\n\n <!-- Right Column -->\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">System Color Theme</label>\n <select class=\"form-select\" id=\"system-theme-select\" style=\"width:100%\">\n ${Object.entries(THEMES).map(([key, val]) => `\n <option value=\"${key}\" ${currentTheme === key ? 'selected' : ''}>${val.name}</option>\n `).join('')}\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Select a color profile and visual background effect for your user interface.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', async (e) => {\n const select = e.target;\n select.disabled = true;\n try {\n const settings = store.getSettings();\n settings.tooltipPreference = select.value;\n await store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved successfully', 'success');\n } catch (err) {\n console.error('Error saving tooltip preference:', err);\n showToast('Failed to save preferences: ' + (err.message || err), 'error');\n } finally {\n select.disabled = false;\n }\n });\n\n tc.querySelector('#system-theme-select')?.addEventListener('change', (e) => {\n applyTheme(e.target.value, true);\n showToast('System theme updated successfully', 'success');\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a complete demonstration dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load a complete, realistic demonstration dataset representing an active trade business (5 customers, 5 quotes/jobs per customer, 5 assets per customer, 5 materials, 5 users/timesheets, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for testing and walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed Demonstration Data\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Demo Data\",\n className: \"btn-primary\",\n onClick: async (close) => {\n close();\n showToast('Seeding demonstration data...', 'info');\n await seedMinimalData();\n showToast('Demonstration data seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-delete-company')?.addEventListener('click', () => {\n // Modal Warning 1 of 2\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">warning</span>\n <span>You are about to permanently delete the entire company profile for <strong>${escapeHTML(store.getSettings().name || '')}</strong> and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).</span>\n </div>\n <p style=\"font-size:12.5px; color:var(--text-secondary); margin-bottom:12px\">\n This action is final, irreversible, and cannot be undone under any circumstances.\n </p>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Do you want to proceed to password confirmation?</p>\n `;\n\n showModal({\n title: \"Delete Company Profile - Step 1 of 2\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (closeModal) => closeModal()\n },\n {\n label: \"Proceed to Confirm\",\n className: \"btn-danger\",\n onClick: (closeModal) => {\n closeModal();\n \n // Modal Warning 2 of 2\n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <form id=\"delete-company-confirm-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <p style=\"margin-bottom:8px; font-weight:600; color:var(--color-danger)\">FINAL CONFIRMATION (2 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">gavel</span>\n <span>To authorize the permanent destruction of this company, you must enter the Administrator password.</span>\n </div>\n\n <div class=\"form-group\" style=\"margin-top:12px;\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Password</label>\n <input class=\"form-input\" type=\"password\" id=\"delete-confirm-password\" required placeholder=\"Enter admin password to proceed\" />\n </div>\n\n <div id=\"delete-confirm-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"delete-confirm-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-delete-abort\">Abort Deletion</button>\n <button type=\"submit\" class=\"btn btn-danger\" id=\"btn-delete-confirm-submit\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span>\n <span>Permanently Delete Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close: close2 } = showModal({\n title: \"⚠️ Confirm Company Deletion - Step 2 of 2\",\n content: content2,\n size: \"modal-md\"\n });\n\n content2.querySelector('#btn-delete-abort').addEventListener('click', close2);\n\n const form2 = content2.querySelector('#delete-company-confirm-form');\n form2.addEventListener('submit', async (ev) => {\n ev.preventDefault();\n\n const errorEl = content2.querySelector('#delete-confirm-error');\n const errorTextEl = content2.querySelector('#delete-confirm-error-text');\n const submitBtn = content2.querySelector('#btn-delete-confirm-submit');\n const abortBtn = content2.querySelector('#btn-delete-abort');\n const passwordInput = content2.querySelector('#delete-confirm-password').value;\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n abortBtn.disabled = true;\n const origSubmitText = submitBtn.innerHTML;\n submitBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Deleting...';\n\n try {\n let passwordVerified = false;\n\n if (isLocalMode) {\n if (localDeploymentType === 'single_user') {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acct = storedAccounts.find(a => a.id === activeAccountId);\n if (acct && acct.hasPassword) {\n const hashedInput = await hashPassword(passwordInput);\n if (hashedInput === acct.passwordHash) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n // No password set\n passwordVerified = true;\n }\n } else {\n // local_multiuser\n const techs = store.getAll('technicians') || [];\n const currentTech = techs.find(t => t.id === currentUser.id);\n const expectedPassword = currentTech ? currentTech.password : '123456';\n if (passwordInput === expectedPassword) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n }\n } else {\n // Cloud mode\n const { supabase } = await import('../utils/supabase.js');\n const { data: { user } } = await supabase.auth.getUser();\n if (user && user.email) {\n const { error } = await supabase.auth.signInWithPassword({\n email: user.email,\n password: passwordInput\n });\n if (!error) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n throw new Error('Could not retrieve logged-in cloud user credentials.');\n }\n }\n\n if (passwordVerified) {\n // 1. If cloud mode, delete the company row in Supabase\n if (!isLocalMode) {\n const { supabase } = await import('../utils/supabase.js');\n const { error: deleteErr } = await supabase.from('companies').delete().eq('id', store.companyId);\n if (deleteErr) {\n throw new Error('Failed to delete cloud company: ' + deleteErr.message);\n }\n }\n\n // 2. Trigger store.clearAll()\n store.clearAll();\n\n // 3. Clear local account launcher keys if local mode\n if (isLocalMode) {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const updatedAccounts = storedAccounts.filter(a => a.id !== activeAccountId);\n await storageSet('relay_accounts', updatedAccounts);\n }\n }\n\n // 4. Remove session user & active account\n localStorage.removeItem('currentUser');\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Company profile deleted successfully.', 'success');\n close2();\n\n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n } catch (err) {\n console.error('Company deletion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during verification.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n abortBtn.disabled = false;\n submitBtn.innerHTML = origSubmitText;\n }\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-convert-multiuser')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n\n const techs = store.getAll('technicians') || [];\n const companyId = store.companyId || '';\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const existingAdmin = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n const currentTechName = existingAdmin ? existingAdmin.name : (currentUser.name && currentUser.name !== 'Local Admin' ? currentUser.name : '');\n const currentTechUsernameOrEmail = existingAdmin ? (existingAdmin.email || existingAdmin.username || '') : '';\n\n content.innerHTML = `\n <form id=\"convert-multiuser-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Multi-User Local Network Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a single-user Local Admin profile.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"multiuser-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"e.g. Apex Power Services\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"multiuser-admin-name\" required value=\"${escapeHTML(currentTechName)}\" placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Email Address (Required)</label>\n <input class=\"form-input\" type=\"email\" id=\"multiuser-admin-email\" required value=\"${escapeHTML(currentTechUsernameOrEmail)}\" placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Admin Password</label>\n <input class=\"form-input\" type=\"password\" id=\"multiuser-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"multiuser-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"multiuser-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-multiuser-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-multiuser-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">lan</span>\n <span>Convert Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: \"Convert to Multi-User Local Sync\",\n content: content,\n size: \"modal-md\"\n });\n\n content.querySelector('#btn-multiuser-cancel').addEventListener('click', close);\n\n const form = content.querySelector('#convert-multiuser-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n const errorEl = content.querySelector('#multiuser-error');\n const errorTextEl = content.querySelector('#multiuser-error-text');\n const submitBtn = content.querySelector('#btn-multiuser-submit');\n const cancelBtn = content.querySelector('#btn-multiuser-cancel');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n\n const businessName = content.querySelector('#multiuser-company-name').value.trim();\n const adminName = content.querySelector('#multiuser-admin-name').value.trim();\n const adminEmail = content.querySelector('#multiuser-admin-email').value.trim();\n const adminPassword = content.querySelector('#multiuser-admin-password').value;\n\n try {\n if (!businessName || !adminName || !adminEmail || !adminPassword) {\n throw new Error('All fields are required.');\n }\n if (adminPassword.length < 6) {\n throw new Error('Password must be at least 6 characters.');\n }\n\n // 1. Update settings\n const settings = store.getSettings();\n settings.name = businessName;\n settings.localDeploymentType = 'multi_user';\n await store.saveSettings(settings);\n\n // 2. Update launcher accounts businessName\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acctIndex = storedAccounts.findIndex(a => a.id === activeAccountId);\n if (acctIndex !== -1) {\n storedAccounts[acctIndex].businessName = businessName;\n await storageSet('relay_accounts', storedAccounts);\n }\n }\n\n // 3. Create or update admin user in technicians\n const companyId = store.companyId;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const techs = store.getAll('technicians') || [];\n let adminTech = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n if (!adminTech) {\n adminTech = {\n id: `${companyId}_tech_admin_created`,\n role: 'Admin',\n color: '#FF5C00',\n userTypeId: adminTypeId,\n payRate: 100.00,\n phone: ''\n };\n techs.push(adminTech);\n }\n\n adminTech.name = adminName;\n adminTech.username = adminEmail.split('@')[0];\n adminTech.email = adminEmail;\n adminTech.password = adminPassword;\n\n store.save('technicians', techs);\n\n // 4. Update currentUser session in localStorage\n const localUser = {\n id: adminTech.id,\n companyId: companyId,\n name: adminTech.name,\n role: 'admin',\n userTypeName: 'Admin',\n userTypeId: adminTech.userTypeId,\n color: adminTech.color || '#FF5C00',\n theme: 'light'\n };\n localStorage.setItem('currentUser', JSON.stringify(localUser));\n\n showToast('Converted to Multi-User Local Network Sync. Reloading...', 'success');\n close();\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n setTimeout(() => window.location.reload(), 1200);\n } catch (err) {\n console.error('Conversion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during conversion.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n }\n });\n });\n\n tc.querySelector('#btn-convert-cloud-action')?.addEventListener('click', () => {\n openMigrationModal();\n });\n\n const renderBackupStatus = () => {\n const bsc = tc.querySelector('#backup-status-container');\n if (!bsc) return;\n\n const hasHandle = !!store.backupDirHandle;\n const isPermissionGranted = store.backupDirPermissionGranted;\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const formattedLastBackup = lastBackup ? new Date(lastBackup).toLocaleString() : 'Never';\n const isSupported = typeof window !== 'undefined' && window.showDirectoryPicker;\n\n if (!isSupported) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-danger); line-height:1.4;\">\n <strong style=\"display:block; margin-bottom:4px;\">Browser Local Folder Access Unsupported</strong>\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n </div>\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:12px; border-radius:6px; margin-bottom:12px; color:var(--text-secondary); display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary);\">folder_off</span>\n <div style=\"font-size:12.5px;\">No backup folder configured.</div>\n </div>\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">folder</span> Choose Backup Folder...\n </button>\n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-warning); line-height:1.4; margin-bottom:12px;\">\n <strong>Permission Required</strong><br/>\n Access permission to <strong>${escapeHTML(store.backupDirHandle.name)}</strong> has expired. Please re-authorize.\n </div>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-warning\" id=\"btn-backup-auth\" style=\"flex:1; justify-content:center;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize & Backup\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n `;\n } else {\n bsc.innerHTML = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-success); line-height:1.4; margin-bottom:12px;\">\n <strong>Backup Configured</strong><br/>\n Folder: <strong>${escapeHTML(store.backupDirHandle.name)}</strong><br/>\n Last Backup: <strong>${formattedLastBackup}</strong>\n </div>\n <div style=\"display:flex; flex-direction:column; gap:8px;\">\n <button class=\"btn btn-primary\" id=\"btn-backup-now\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">backup</span> Backup Now\n </button>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"flex:1; justify-content:center; border:1px solid var(--border-color);\">\n <span class=\"material-icons-outlined\">folder</span> Change Folder...\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n </div>\n `;\n }\n\n // Attach event listeners for backup buttons inside container\n bsc.querySelector('#btn-backup-pick')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setBackupDirectory(handle);\n showToast('Backup directory selected successfully.', 'success');\n showToast('Running initial backup...', 'info');\n await store.backupToFolder(handle);\n showToast('Backup completed successfully!', 'success');\n renderBackupStatus();\n } catch (err) {\n console.error('Failed to select backup directory:', err);\n if (err.name !== 'AbortError') {\n showToast('Failed to configure backup: ' + err.message, 'error');\n }\n }\n });\n\n bsc.querySelector('#btn-backup-auth')?.addEventListener('click', async () => {\n const granted = await store.verifyBackupDirPermission(true);\n if (granted) {\n showToast('Permission re-authorized. Backing up...', 'info');\n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n showToast('Backup failed: ' + err.message, 'error');\n }\n } else {\n showToast('Failed to acquire write permission.', 'error');\n }\n renderBackupStatus();\n });\n\n bsc.querySelector('#btn-backup-now')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Backing up...';\n \n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Backup failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n renderBackupStatus();\n }\n });\n\n bsc.querySelector('#btn-backup-disconnect')?.addEventListener('click', async () => {\n await store.setBackupDirectory(null);\n showToast('Backup folder disconnected.', 'info');\n renderBackupStatus();\n });\n };\n\n if (!isLocalMode && currentUser.role === 'admin') {\n renderBackupStatus();\n }\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', username: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Username</label>\n <input class=\"form-input\" id=\"u-username\" value=\"${t.username || (t.email ? t.email.split('@')[0] : '')}\" ${editId ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''} placeholder=\"e.g. joshua\" />\n ${!editId ? `\n <div style=\"font-size: 11px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500;\">\n Company login code is: <strong style=\"color: var(--color-primary)\">${companySlug}</strong>. User will log in with <strong style=\"color: var(--color-primary)\">username@${companySlug}</strong>\n </div>\n ` : ''}\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">${editId ? 'Reset Password (leave blank to keep current)' : 'Temporary Password (assigned for worker login)'}</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"${editId ? '••••••••' : 'Min. 6 characters'}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\" ${(t.userTypeId === 'ut_admin' || (t.userTypeId && t.userTypeId.endsWith('_ut_admin'))) ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''}>\n <option value=\"\">-- Select --</option>\n ${userTypes\n .filter(ut => (!ut.id.endsWith('_ut_admin') && ut.id !== 'ut_admin') || t.userTypeId === ut.id)\n .map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value.trim();\n const username = document.getElementById('u-username').value.trim();\n const role = document.getElementById('u-role').value.trim();\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = document.getElementById('u-password')?.value || '';\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!username) { showToast('Username required', 'error'); return; }\n if (username.includes('@')) { showToast('Username cannot contain @ symbol', 'error'); return; }\n \n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (password && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n const updates = { name, username, role, userTypeId, color, payRate };\n if (password) {\n updates.password = password;\n }\n\n if (editId) {\n await store.update('technicians', editId, updates);\n } else {\n await store.create('technicians', updates);\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = async () => {\n const btn = tc.querySelector('#btn-save-materials');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n await store.saveSettings(updatedSettings);\n showToast('Material settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving material settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-00023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-00129',\n originalQuoteNumber: 'Q-00412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' },\n ballet: { name: 'Ballet Pointe', preset: 'ballet', accentColor: '#e58799', headerBg: '#5c2c36', accentTint: '#fff5f6', fontFamily: 'serif' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-company-name\" style=\"width:16px; height:16px\" ${dt.hideCompanyName ? 'checked' : ''} />\n <label for=\"theme-hide-company-name\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide company name text (use logo only)</label>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Variation</label>\n <select class=\"form-select\" id=\"theme-logo-source\" style=\"width:100%\">\n <option value=\"large\" ${dt.logoSource === 'large' ? 'selected' : ''}>Standard Logo (Large)</option>\n <option value=\"small\" ${dt.logoSource === 'small' ? 'selected' : ''}>Small / Shrunk Logo (Small)</option>\n </select>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:8px 0\"></div>\n <div class=\"form-group ${isLocalMode ? 'disabled-local' : ''}\" ${isLocalMode ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''} style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-brand-logo\" style=\"width:16px; height:16px\" ${dt.hideBrandLogo ? 'checked' : ''} ${isLocalMode ? 'disabled' : ''} />\n <label for=\"theme-hide-brand-logo\" class=\"form-label\" style=\"margin:0; cursor:${isLocalMode ? 'not-allowed' : 'pointer'}\">Remove Relay-dispatch brand logo from document footers</label>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n <div class=\"form-row\" style=\"display:flex; gap:12px\">\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Invoice Prefix</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoicePrefix\" value=\"${escapeHTML(dt.invoicePrefix || 'INV-')}\" placeholder=\"INV-\" />\n </div>\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Invoice Starting Number</label>\n <input type=\"number\" class=\"form-input text-copy-input\" data-prop=\"invoiceStartingNumber\" value=\"${dt.invoiceStartingNumber !== undefined ? dt.invoiceStartingNumber : 1}\" min=\"1\" placeholder=\"1\" />\n </div>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n <div class=\"form-row\" style=\"display:flex; gap:12px\">\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Quote Prefix</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quotePrefix\" value=\"${escapeHTML(dt.quotePrefix || 'Q-')}\" placeholder=\"Q-\" />\n </div>\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Quote Starting Number</label>\n <input type=\"number\" class=\"form-input text-copy-input\" data-prop=\"quoteStartingNumber\" value=\"${dt.quoteStartingNumber !== undefined ? dt.quoteStartingNumber : 1}\" min=\"1\" placeholder=\"1\" />\n </div>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Hide Company Name toggle\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) {\n hideCompanyNameChk.addEventListener('change', () => {\n dt.hideCompanyName = hideCompanyNameChk.checked;\n updatePreview();\n });\n }\n\n // Logo Variation toggle\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) {\n logoSourceSelect.addEventListener('change', (e) => {\n dt.logoSource = e.target.value;\n updatePreview();\n });\n }\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Hide Brand Logo toggle\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) {\n hideBrandLogoChk.addEventListener('change', () => {\n dt.hideBrandLogo = hideBrandLogoChk.checked;\n updatePreview();\n });\n }\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-theme-save');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n await store.saveSettings(freshSettings);\n showToast('Document customization settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving theme settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n hideCompanyName: false,\n hideBrandLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) hideCompanyNameChk.checked = !!dt.hideCompanyName;\n\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) logoSourceSelect.value = dt.logoSource || 'large';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePrefixInp = tc.querySelector('[data-prop=\"invoicePrefix\"]');\n if (invoicePrefixInp) invoicePrefixInp.value = dt.invoicePrefix || '';\n\n const invoiceStartingNumberInp = tc.querySelector('[data-prop=\"invoiceStartingNumber\"]');\n if (invoiceStartingNumberInp) invoiceStartingNumberInp.value = dt.invoiceStartingNumber !== undefined ? dt.invoiceStartingNumber : 1;\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const quotePrefixInp = tc.querySelector('[data-prop=\"quotePrefix\"]');\n if (quotePrefixInp) quotePrefixInp.value = dt.quotePrefix || '';\n\n const quoteStartingNumberInp = tc.querySelector('[data-prop=\"quoteStartingNumber\"]');\n if (quoteStartingNumberInp) quoteStartingNumberInp.value = dt.quoteStartingNumber !== undefined ? dt.quoteStartingNumber : 1;\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) hideBrandLogoChk.checked = !!dt.hideBrandLogo;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n const logoUrl = dt.logoSource === 'small' ? (settings.logoSmall || settings.logo) : (settings.logo || settings.logoSmall);\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.src = logoUrl || '';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // Company Name visibility\n const companyNameEl = frameDoc.querySelector('.pdf-company-name');\n if (companyNameEl) {\n companyNameEl.style.display = dt.hideCompanyName ? 'none' : 'block';\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n\n const footerBrandingEl = frameDoc.querySelector('.pdf-footer-branding');\n if (footerBrandingEl) {\n const hideBrandLogo = isLocalMode ? false : !!dt.hideBrandLogo;\n footerBrandingEl.style.display = hideBrandLogo ? 'none' : 'flex';\n }\n }\n\n renderMarkup();\n }\n\n function renderFolderSyncTab(tc) {\n const isSupported = typeof window !== 'undefined' && (\n (window.indexedDB && window.showDirectoryPicker) ||\n (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem)\n );\n\n const isCapacitor = typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem;\n\n const activeAccountId = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n const isLocalCompany = !!activeAccountId;\n\n function render() {\n console.log('[DEBUG Settings] render: isLocalCompany =', isLocalCompany, 'store.dirHandle =', store.dirHandle, 'permissionGranted =', store.folderSyncPermissionGranted);\n const isEnabled = isLocalCompany ? true : store.folderSyncEnabled;\n const isPermissionGranted = store.folderSyncPermissionGranted;\n const hasHandle = !!store.dirHandle;\n\n let statusHtml = '';\n if (!isSupported) {\n statusHtml = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:16px; border-radius:4px; color:var(--color-danger); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:8px; font-weight:600; margin-bottom:4px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Browser Directory Access Unsupported</span>\n </div>\n <p style=\"font-size:var(--font-size-sm); margin:0; line-height:1.4;\">\n Your current browser does not support local folder access. To enable direct folder synchronization, please run this app in a Chromium-based browser (Chrome, Edge, Opera, or as a compiled native app). High-capacity IndexedDB storage remains active.\n </p>\n </div>\n `;\n } else if (!isEnabled) {\n statusHtml = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:16px; border-radius:6px; color:var(--text-secondary); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px; color:var(--text-tertiary);\">folder_off</span>\n <div>\n <div style=\"font-weight:600; color:var(--text-primary); margin-bottom:2px;\">Folder Synchronization Inactive</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4;\">All data is currently stored in your browser's private IndexedDB database sandbox.</p>\n </div>\n </div>\n `;\n } else if (isCapacitor) {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">cloud_done</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Capacitor Direct Folder Sync Active</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Data is synchronizing directly to the application's native <strong>Documents/RelayDispatchData</strong> directory on your iPad/device.\n </p>\n </div>\n </div>\n `;\n } else if (!hasHandle) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">warning</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">No Directory Selected</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">Please select a directory folder on your computer to begin syncing.</p>\n </div>\n </div>\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:12px; margin-bottom:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">lock</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Access Permission Suspended</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n The browser requires re-authorization to read/write files in <strong>${escapeHTML(store.dirHandle.name)}</strong>.\n </p>\n </div>\n </div>\n <button class=\"btn btn-warning\" id=\"btn-reauthorize-dir\" style=\"margin-left:44px;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize Access\n </button>\n </div>\n `;\n } else {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">check_circle</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Folder Sync Active & Synchronized</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Active Folder: <strong>${escapeHTML(store.dirHandle.name)}</strong>. All data edits are writing dynamically.\n </p>\n </div>\n </div>\n `;\n }\n\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <!-- Folder Configuration -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Direct Directory Sync</h4></div>\n <div class=\"card-body\">\n ${statusHtml}\n\n <div class=\"form-group\" style=\"margin-bottom:var(--space-lg);\">\n <label class=\"form-label\" style=\"font-weight:600; margin-bottom:8px;\">Synchronization Toggle</label>\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\">\n <input type=\"checkbox\" id=\"toggle-folder-sync\" ${isEnabled ? 'checked' : ''} ${(!isSupported || isLocalCompany) ? 'disabled' : ''} style=\"width:20px; height:20px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\" />\n <div>\n <span style=\"font-weight:500;\">Enable Direct Local Folder Storage</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">\n ${isLocalCompany ? 'Mandatory for offline/local company profile storage.' : 'Saves all data records and attachments straight to your machine.'}\n </div>\n </div>\n </label>\n </div>\n\n ${isEnabled && isSupported && !isCapacitor ? `\n <div style=\"display:flex; gap:12px; flex-wrap:wrap; border-top:1px solid var(--border-color); padding-top:var(--space-lg);\">\n <button class=\"btn btn-secondary\" id=\"btn-pick-dir\">\n <span class=\"material-icons-outlined\">folder</span> Choose Sync Folder...\n </button>\n ${hasHandle && isPermissionGranted ? `\n <button class=\"btn btn-secondary\" id=\"btn-force-sync\" data-tooltip=\"Overwrite folder JSONs with current memory cache\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">sync</span> Sync Now\n </button>\n ${!isLocalCompany ? `\n <button class=\"btn btn-danger\" id=\"btn-disconnect-dir\">\n <span class=\"material-icons-outlined\">link_off</span> Disconnect Folder\n </button>\n ` : ''}\n ` : ''}\n </div>\n ` : ''}\n </div>\n </div>\n\n <!-- Instructions Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>How Self-Hosting Works</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p>\n By linking a local folder, you establish a <strong>serverless self-hosted data hub</strong>.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">storage</span>\n <span><strong>Readable Data:</strong> Your jobs, quotes, and company configurations are saved in <code>data/*.json</code> files.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">photo_library</span>\n <span><strong>Physical Attachments:</strong> Uploaded photos and PDF catalogs are saved as standard image/PDF files under the <code>documents/</code> subfolder.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">sync_alt</span>\n <span><strong>Cloud Backups:</strong> Select a folder synced to <strong>OneDrive, Google Drive, or Dropbox</strong> to automatically back up and sync your data to the cloud.</span>\n </div>\n </div>\n </div>\n </div>\n `;\n\n // Event Listeners\n const toggle = tc.querySelector('#toggle-folder-sync');\n toggle?.addEventListener('change', async (e) => {\n if (isLocalCompany) {\n e.preventDefault();\n toggle.checked = true;\n return;\n }\n\n if (isCapacitor) {\n await store.setLocalDirectory(e.target.checked ? {} : null);\n showToast(e.target.checked ? 'Capacitor folder sync activated' : 'Folder sync deactivated', 'success');\n render();\n return;\n }\n\n if (e.target.checked) {\n // Trigger directory picker\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory configured successfully', 'success');\n } catch (err) {\n console.error('Directory selection cancelled or failed:', err);\n toggle.checked = false;\n showToast('Folder selection cancelled', 'info');\n }\n } else {\n await store.setLocalDirectory(null);\n showToast('Direct directory synchronization disabled', 'info');\n }\n render();\n });\n\n tc.querySelector('#btn-pick-dir')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory updated successfully', 'success');\n render();\n } catch (err) {\n console.error(err);\n }\n });\n\n tc.querySelector('#btn-reauthorize-dir')?.addEventListener('click', async () => {\n const granted = await store.verifyDirPermission(true);\n if (granted) {\n showToast('Folder access re-authorized successfully', 'success');\n } else {\n showToast('Failed to acquire write permissions', 'error');\n }\n render();\n });\n\n tc.querySelector('#btn-disconnect-dir')?.addEventListener('click', async () => {\n await store.setLocalDirectory(null);\n showToast('Local folder disconnected', 'info');\n render();\n });\n\n tc.querySelector('#btn-force-sync')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n btn.disabled = true;\n showToast('Writing cache data to local directory...', 'info');\n try {\n const collections = Object.keys(store.cache);\n await Promise.all(collections.map(col => store.writeCollectionToFolder(col, store.cache[col])));\n showToast('Folder sync completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Folder sync failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n }\n });\n }\n\n render();\n }\n\n function renderApiKeysTab(tc) {\n const s = store.getSettings();\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const ai = s.ai || {\n enabled: false,\n apiKey: '',\n visionApiKey: '',\n useSameKey: true,\n endpoint: 'https://api.deepseek.com/chat/completions',\n model: 'deepseek-chat',\n visionEndpoint: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',\n visionModel: 'gemini-2.0-flash',\n systemPrompt: 'You are Deputy, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n };\n if (ai.useSameKey === undefined) ai.useSameKey = true;\n const maps = s.maps || { apiKey: '' };\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null') || { id: 'default' };\n const userId = currentUser.id || 'default';\n const factsheetKey = `relay_factsheet_${userId}`;\n const enabledKey = `relay_factsheet_enabled_${userId}`;\n const factsheetVal = localStorage.getItem(factsheetKey) || '';\n const memoryEnabled = localStorage.getItem(enabledKey) !== 'false';\n\n function render() {\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg);\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>AI Assistant Configuration</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n \n <div class=\"form-group\">\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:pointer;\">\n <input type=\"checkbox\" id=\"ai-enabled\" ${ai.enabled ? 'checked' : ''} style=\"width:20px; height:20px; cursor:pointer;\" />\n <div>\n <span style=\"font-weight:600;\">Enable AI Assistant</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">Replaces the basic rule-based Relay co-pilot with a smart conversational LLM.</div>\n </div>\n </label>\n </div>\n\n <div id=\"ai-fields\" style=\"display: ${ai.enabled ? 'flex' : 'none'}; flex-direction:column; gap:16px; border-top:1px solid var(--border-color); padding-top:16px;\">\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Text API Key</label>\n <input type=\"password\" class=\"form-input\" id=\"ai-apikey\" value=\"${ai.apiKey || ''}\" placeholder=\"${isLocalMode ? 'sk-...' : 'Optional — Defaults to secure cloud API key'}\" />\n </div>\n <div class=\"form-group\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <label class=\"form-label\" style=\"font-weight:600; margin-bottom:0;\">Vision API Key</label>\n <label style=\"display:flex; align-items:center; gap:6px; font-size:12px; cursor:pointer;\">\n <input type=\"checkbox\" id=\"ai-use-same-key\" ${ai.useSameKey ? 'checked' : ''} style=\"width:14px; height:14px;\" /> Use Text API Key\n </label>\n </div>\n <input type=\"password\" class=\"form-input\" id=\"ai-vision-apikey\" value=\"${ai.useSameKey ? (ai.apiKey || '') : (ai.visionApiKey || '')}\" placeholder=\"API Key for Vision Model\" style=\"margin-top:6px; ${ai.useSameKey ? 'opacity:0.5; pointer-events:none;' : ''}\" />\n </div>\n </div>\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:-8px;\">${isLocalMode ? 'Your API keys are stored locally in your browser/sync directory.' : 'Keys are optional. If blank, cloud operations will route securely through Edge Functions.'}</div>\n\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Text API Endpoint</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-endpoint\" value=\"${ai.endpoint || 'https://api.deepseek.com/chat/completions'}\" placeholder=\"https://api.deepseek.com/chat/completions\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Text Model Name</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-model\" value=\"${ai.model || 'deepseek-chat'}\" placeholder=\"deepseek-chat\" />\n </div>\n </div>\n\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Vision API Endpoint (Multimodal)</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-vision-endpoint\" value=\"${ai.visionEndpoint || 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions'}\" placeholder=\"https://api.openai.com/v1/chat/completions\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Vision Model Name</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-vision-model\" value=\"${ai.visionModel || 'gemini-2.0-flash'}\" placeholder=\"gpt-4o-mini\" />\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Base System Prompt</label>\n <textarea class=\"form-input\" id=\"ai-systemprompt\" rows=\"4\" style=\"font-family:inherit; resize:vertical;\">${ai.systemPrompt || ''}</textarea>\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.</div>\n </div>\n\n <div style=\"display:flex; gap:12px; align-items:center; margin-top:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-test-ai\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">bolt</span> Test Connection\n </button>\n <span id=\"test-status\" style=\"font-size:13px; font-weight:500;\"></span>\n </div>\n <div id=\"test-error-details\" style=\"display:none; margin-top:8px; font-size:12px; color:var(--color-danger); background:var(--color-danger-bg); border-left:3px solid var(--color-danger); padding:8px; border-radius:4px; line-height:1.4;\"></div>\n </div>\n\n <div style=\"border-top:1px solid var(--border-color); padding-top:16px; display:flex; justify-content:flex-end;\">\n <button class=\"btn btn-primary\" id=\"btn-save-ai\">Save AI Config</button>\n </div>\n\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Google Maps Configuration</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Maps API Key</label>\n <input type=\"password\" class=\"form-input\" id=\"maps-apikey\" value=\"${maps.apiKey || ''}\" placeholder=\"AIzaSy...\" />\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">Used for address autocomplete, distance calculations, and rendering maps. Get this from the Google Cloud Console.</div>\n </div>\n <div style=\"border-top:1px solid var(--border-color); padding-top:16px; display:flex; justify-content:flex-end;\">\n <button class=\"btn btn-primary\" id=\"btn-save-maps\">Save Maps Config</button>\n </div>\n </div>\n </div>\n\n </div>\n\n <!-- Cheapest Smart AI Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>Cheapest Smart AI Guide</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p style=\"margin:0;\">\n <strong>DeepSeek</strong> is one of the most cost-efficient and high-performance language models currently available, making it perfect for lightweight co-pilot tasks.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">key</span>\n <span><strong>API Key:</strong> Get your key from <a href=\"https://platform.deepseek.com/\" target=\"_blank\" style=\"color:var(--color-primary); text-decoration:underline;\">platform.deepseek.com</a>. New accounts typically receive free trial credits.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">security</span>\n <span><strong>CORS Restrictions:</strong> Due to browser security, direct API calls to DeepSeek may be blocked. To bypass this:\n <ul style=\"margin:4px 0 0 16px; padding:0; list-style:disc; font-size:12px;\">\n <li>Route via a CORS proxy (e.g. <code>https://cors-anywhere.herokuapp.com/</code> prefix).</li>\n <li>Connect via an aggregator like <strong>OpenRouter</strong> (API Endpoint: <code>https://openrouter.ai/api/v1/chat/completions</code>).</li>\n <li>Use a local LLM runner like <strong>Ollama</strong> (API Endpoint: <code>http://localhost:11434/v1/chat/completions</code>).</li>\n </ul>\n </span>\n </div>\n </div>\n </div>\n\n </div>\n `;\n\n // Toggle display of fields based on enabled checkbox\n const enabledCheckbox = tc.querySelector('#ai-enabled');\n const fieldsContainer = tc.querySelector('#ai-fields');\n enabledCheckbox.addEventListener('change', (e) => {\n fieldsContainer.style.display = e.target.checked ? 'flex' : 'none';\n });\n\n const useSameKeyCheckbox = tc.querySelector('#ai-use-same-key');\n const visionApiKeyInput = tc.querySelector('#ai-vision-apikey');\n const textApiKeyInput = tc.querySelector('#ai-apikey');\n if (useSameKeyCheckbox && visionApiKeyInput) {\n useSameKeyCheckbox.addEventListener('change', (e) => {\n if (e.target.checked) {\n visionApiKeyInput.style.opacity = '0.5';\n visionApiKeyInput.style.pointerEvents = 'none';\n if (textApiKeyInput) visionApiKeyInput.value = textApiKeyInput.value;\n } else {\n visionApiKeyInput.style.opacity = '1';\n visionApiKeyInput.style.pointerEvents = 'auto';\n visionApiKeyInput.focus();\n }\n });\n \n if (textApiKeyInput) {\n textApiKeyInput.addEventListener('input', (e) => {\n if (useSameKeyCheckbox.checked) {\n visionApiKeyInput.value = e.target.value;\n }\n });\n }\n }\n\n\n\n // Save handler\n tc.querySelector('#btn-save-ai').addEventListener('click', () => {\n const enabled = enabledCheckbox.checked;\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const useSameKey = tc.querySelector('#ai-use-same-key') ? tc.querySelector('#ai-use-same-key').checked : true;\n const visionApiKey = useSameKey ? apiKey : tc.querySelector('#ai-vision-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const visionEndpoint = tc.querySelector('#ai-vision-endpoint').value.trim();\n const visionModel = tc.querySelector('#ai-vision-model').value.trim();\n const systemPrompt = tc.querySelector('#ai-systemprompt').value.trim();\n\n if (isLocalMode && enabled && !apiKey) {\n showToast('Text API Key is required when enabling the AI assistant.', 'error');\n return;\n }\n\n const settings = store.getSettings();\n settings.ai = {\n enabled,\n apiKey,\n visionApiKey,\n useSameKey,\n endpoint,\n model,\n visionEndpoint,\n visionModel,\n systemPrompt\n };\n\n store.saveSettings(settings);\n showToast('AI settings saved successfully', 'success');\n });\n\n tc.querySelector('#btn-save-maps').addEventListener('click', () => {\n const apiKey = tc.querySelector('#maps-apikey').value.trim();\n const settings = store.getSettings();\n settings.maps = { apiKey };\n store.saveSettings(settings);\n showToast('Maps settings saved successfully', 'success');\n });\n\n // Test handler\n tc.querySelector('#btn-test-ai').addEventListener('click', async () => {\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const statusEl = tc.querySelector('#test-status');\n const errEl = tc.querySelector('#test-error-details');\n\n const inElectron = !!(window.electronAPI && window.electronAPI.callDeepSeek);\n const hasEnvKey = !!(import.meta.env.VITE_DEEPSEEK_API_KEY);\n\n if (!apiKey && !inElectron && !hasEnvKey) {\n showToast('Enter an API key to test.', 'error');\n return;\n }\n\n statusEl.style.color = 'var(--text-secondary)';\n statusEl.textContent = 'Testing connection...';\n errEl.style.display = 'none';\n\n const startTime = Date.now();\n try {\n if (inElectron) {\n const data = await window.electronAPI.callDeepSeek({\n messages: [{ role: 'user', content: 'respond only with the word OK' }],\n endpoint,\n model,\n apiKey // Pass typed key if any\n });\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success (via Electron IPC)! Latency: ${latency}ms (Reply: \"${reply}\")`;\n } else {\n const res = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY}`\n },\n body: JSON.stringify({\n model: model || 'deepseek-chat',\n messages: [\n { role: 'user', content: 'respond only with the word OK' }\n ],\n max_tokens: 5\n })\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`HTTP ${res.status}: ${body}`);\n }\n\n const data = await res.json();\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success! Latency: ${latency}ms (Reply: \"${reply}\")`;\n }\n } catch (err) {\n console.error('AI test connection failed:', err);\n statusEl.style.color = 'var(--color-danger)';\n statusEl.textContent = 'Test failed';\n errEl.style.display = 'block';\n\n let errMsg = err.message;\n if (err.name === 'TypeError' && err.message.includes('Failed to fetch')) {\n errMsg = 'CORS Blocked or Offline. The browser blocked this cross-origin request. Please use a CORS proxy, an API gateway like OpenRouter, or check your internet connection.';\n }\n errEl.textContent = errMsg;\n }\n });\n }\n\n render();\n }\n\n function renderCostCentersTab(tc) {\n const list = store.getAll('costCenters') || [];\n\n const renderTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:900px\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Cost Centers</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-cost-center\" style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span> Add Cost Center\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"padding-left:16px; width:120px\">Code</th>\n <th>Name</th>\n <th>Xero Mapping</th>\n <th style=\"width:120px\">Status</th>\n <th style=\"text-align:right; padding-right:16px; width:180px\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${list.length === 0 ? `\n <tr>\n <td colspan=\"5\" style=\"text-align:center; padding:32px 16px; color:var(--text-secondary)\">\n No Cost Centers configured. Click \"Add Cost Center\" to create one.\n </td>\n </tr>\n ` : list.map(cc => `\n <tr>\n <td style=\"padding-left:16px; font-weight:600\">${escapeHTML(cc.code)}</td>\n <td>${escapeHTML(cc.name)}</td>\n <td>\n ${cc.xeroSalesAccountCode || cc.xeroTrackingOptionName ? `\n <div style=\"font-size:11px; line-height:1.4\">\n ${cc.xeroSalesAccountCode ? `<div><span class=\"text-tertiary\">Sales Code:</span> <strong>${escapeHTML(cc.xeroSalesAccountCode)}</strong></div>` : ''}\n ${cc.xeroExpenseAccountCode ? `<div><span class=\"text-tertiary\">Expense Code:</span> <strong>${escapeHTML(cc.xeroExpenseAccountCode)}</strong></div>` : ''}\n ${cc.xeroTrackingOptionName ? `<div><span class=\"text-tertiary\">Tracking:</span> <strong>${escapeHTML(cc.xeroTrackingCategoryName || 'Department')}:${escapeHTML(cc.xeroTrackingOptionName)}</strong></div>` : ''}\n </div>\n ` : '<span class=\"text-tertiary\" style=\"font-size:11px\">— Unmapped —</span>'}\n </td>\n <td>\n <span class=\"badge ${cc.active ? 'badge-success' : 'badge-neutral'}\">\n ${cc.active ? 'Active' : 'Inactive'}\n </span>\n </td>\n <td style=\"text-align:right; padding-right:16px\">\n <button class=\"btn btn-secondary btn-sm btn-edit-cc\" data-id=\"${cc.id}\" style=\"margin-right:6px\">Edit</button>\n <button class=\"btn btn-outline btn-sm btn-toggle-cc\" data-id=\"${cc.id}\">\n ${cc.active ? 'Disable' : 'Enable'}\n </button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-cost-center')?.addEventListener('click', () => openCostCenterModal());\n tc.querySelectorAll('.btn-edit-cc').forEach(btn => {\n btn.addEventListener('click', (e) => openCostCenterModal(e.target.dataset.id));\n });\n tc.querySelectorAll('.btn-toggle-cc').forEach(btn => {\n btn.addEventListener('click', async (e) => {\n const id = e.target.dataset.id;\n const cc = store.getById('costCenters', id);\n if (cc) {\n const newActive = !cc.active;\n await store.update('costCenters', id, { active: newActive });\n showToast(`Cost center ${cc.name} has been ${newActive ? 'enabled' : 'disabled'}.`, 'success');\n renderCostCentersTab(tc);\n }\n });\n });\n };\n\n renderTab();\n }\n\n function openCostCenterModal(editId = null) {\n let cc = editId ? store.getById('costCenters', editId) : { \n code: '', \n name: '', \n active: true,\n xeroSalesAccountCode: '',\n xeroExpenseAccountCode: '',\n xeroTrackingCategoryName: 'Department',\n xeroTrackingOptionName: ''\n };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\" style=\"margin-bottom:16px\">\n <label class=\"form-label\" style=\"display:block; margin-bottom:6px\">Cost Center Code</label>\n <input class=\"form-input\" id=\"cc-code\" value=\"${escapeHTML(cc.code)}\" placeholder=\"e.g. ELEC\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\" style=\"margin-bottom:16px\">\n <label class=\"form-label\" style=\"display:block; margin-bottom:6px\">Cost Center Name</label>\n <input class=\"form-input\" id=\"cc-name\" value=\"${escapeHTML(cc.name)}\" placeholder=\"e.g. Electrical Services\" style=\"width:100%\" />\n </div>\n \n <fieldset style=\"border: 1px solid var(--border-color); border-radius: 6px; padding: 12px; margin-bottom: 16px; background:var(--card-bg)\">\n <legend style=\"padding: 0 6px; font-size: 11px; font-weight: 600; color: var(--color-primary); display: flex; align-items: center; gap: 4px; margin: 0\">\n <span class=\"material-icons-outlined\" style=\"font-size:15px\">sync</span> Xero Integration (Optional)\n </legend>\n <div class=\"form-row\" style=\"margin-bottom:12px; display:grid; grid-template-columns:1fr 1fr; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Sales Account Code</label>\n <input class=\"form-input\" id=\"cc-xero-sales\" value=\"${escapeHTML(cc.xeroSalesAccountCode || '')}\" placeholder=\"e.g. 200\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Expense Account Code</label>\n <input class=\"form-input\" id=\"cc-xero-expense\" value=\"${escapeHTML(cc.xeroExpenseAccountCode || '')}\" placeholder=\"e.g. 300\" style=\"width:100%\" />\n </div>\n </div>\n <div class=\"form-row\" style=\"display:grid; grid-template-columns:1fr 1fr; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Tracking Category</label>\n <input class=\"form-input\" id=\"cc-xero-category\" value=\"${escapeHTML(cc.xeroTrackingCategoryName || 'Department')}\" placeholder=\"e.g. Department\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Tracking Option</label>\n <input class=\"form-input\" id=\"cc-xero-option\" value=\"${escapeHTML(cc.xeroTrackingOptionName || '')}\" placeholder=\"e.g. Electrical\" style=\"width:100%\" />\n </div>\n </div>\n </fieldset>\n\n <div class=\"form-group\">\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer\">\n <input type=\"checkbox\" id=\"cc-active\" ${cc.active ? 'checked' : ''} style=\"width:16px; height:16px; margin:0\" />\n <span>Active</span>\n </label>\n </div>\n `;\n\n showModal({\n title: editId ? 'Edit Cost Center' : 'Add Cost Center',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-cc', onClick: async (c) => {\n const code = document.getElementById('cc-code').value.trim().toUpperCase();\n const name = document.getElementById('cc-name').value.trim();\n const active = document.getElementById('cc-active').checked;\n const xeroSalesAccountCode = document.getElementById('cc-xero-sales').value.trim();\n const xeroExpenseAccountCode = document.getElementById('cc-xero-expense').value.trim();\n const xeroTrackingCategoryName = document.getElementById('cc-xero-category').value.trim();\n const xeroTrackingOptionName = document.getElementById('cc-xero-option').value.trim();\n\n if (!code) { showToast('Code required', 'error'); return; }\n if (!name) { showToast('Name required', 'error'); return; }\n\n try {\n const updates = { \n code, \n name, \n active,\n xeroSalesAccountCode: xeroSalesAccountCode || null,\n xeroExpenseAccountCode: xeroExpenseAccountCode || null,\n xeroTrackingCategoryName: xeroTrackingCategoryName || null,\n xeroTrackingOptionName: xeroTrackingOptionName || null\n };\n if (editId) {\n await store.update('costCenters', editId, updates);\n showToast('Cost Center updated successfully', 'success');\n } else {\n await store.create('costCenters', updates);\n showToast('Cost Center created successfully', 'success');\n }\n c();\n const tc = document.querySelector('#settings-content');\n renderCostCentersTab(tc);\n } catch (err) {\n console.error('Error saving cost center:', err);\n showToast('Failed to save cost center: ' + err.message, 'error');\n }\n }}\n ]\n });\n }\n\n function renderSuppliersSettings(tc) {\n const settings = store.getSettings();\n const categories = settings.supplierCategories || ['Electrical', 'Plumbing', 'HVAC', 'Fire Safety', 'Security', 'General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Supplier Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Define classifications/categories for your suppliers (e.g. Electrical, Plumbing, HVAC). These categories are used to group suppliers in the Suppliers directory.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"supplier-categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${escapeHTML(c)}\n <span class=\"material-icons-outlined btn-remove-supplier-cat\" data-name=\"${escapeHTML(c)}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-supplier-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-suppliers\" data-tooltip=\"Save supplier categories settings\" data-tooltip-pos=\"top\">Save Supplier Settings</button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-supplier-category').addEventListener('click', () => {\n const name = prompt('Enter supplier category name:');\n if (name) {\n const trimmed = name.trim();\n if (!trimmed) return;\n const exists = Array.from(tc.querySelectorAll('.btn-remove-supplier-cat')).some(span => span.dataset.name.toLowerCase() === trimmed.toLowerCase());\n if (exists) {\n showToast('Category already exists', 'error');\n return;\n }\n\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${escapeHTML(trimmed)}\n <span class=\"material-icons-outlined btn-remove-supplier-cat\" data-name=\"${escapeHTML(trimmed)}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#supplier-categories-container').insertBefore(btn, tc.querySelector('#btn-add-supplier-category'));\n btn.querySelector('.btn-remove-supplier-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-supplier-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-suppliers').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-suppliers');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const supplierCategories = Array.from(tc.querySelectorAll('.btn-remove-supplier-cat')).map(span => span.dataset.name);\n const updatedSettings = {\n ...settings,\n supplierCategories\n };\n await store.saveSettings(updatedSettings);\n showToast('Supplier settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving supplier settings:', err);\n showToast('Failed to save supplier settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n }\n"}, "9aa2f3eb9e1c0513aa7ea0da743dfb17607b3861": {"t": 1785754178.5831542, "n": 275625, "h": "2bd60208f0dd9ac9", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData, seedData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\nimport { applyTheme, THEMES } from '../utils/theme.js';\nimport { storageGet, storageSet } from '../utils/tauriStore.js';\n\n// Compress uploaded images using Canvas to avoid huge Base64 data payloads\nfunction compressImage(dataUrl, maxWidth, maxHeight) {\n return new Promise((resolve) => {\n if (!dataUrl) {\n resolve(dataUrl);\n return;\n }\n // SVG vectors are already lightweight; do not compress them\n if (dataUrl.startsWith('data:image/svg+xml')) {\n resolve(dataUrl);\n return;\n }\n const img = new Image();\n img.onload = () => {\n let width = img.width;\n let height = img.height;\n\n if (width > height) {\n if (width > maxWidth) {\n height = Math.round((height * maxWidth) / width);\n width = maxWidth;\n }\n } else {\n if (height > maxHeight) {\n width = Math.round((width * maxHeight) / height);\n height = maxHeight;\n }\n }\n\n const canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n\n const ctx = canvas.getContext('2d');\n ctx.drawImage(img, 0, 0, width, height);\n\n const isPng = dataUrl.includes('image/png');\n const format = isPng ? 'image/png' : 'image/jpeg';\n const quality = isPng ? undefined : 0.85;\n\n resolve(canvas.toDataURL(format, quality));\n };\n img.onerror = () => {\n resolve(dataUrl);\n };\n img.src = dataUrl;\n });\n}\n\n// Helper to hash password using SHA-256 Web Crypto API\nasync function hashPassword(password) {\n const msgBuffer = new TextEncoder().encode(password);\n const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);\n const hashArray = Array.from(new Uint8Array(hashBuffer));\n return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');\n}\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n const localDeploymentType = settings.localDeploymentType || 'single_user';\n\n const isUsersDisabled = isLocalMode && localDeploymentType === 'single_user';\n const isPortalDisabled = isLocalMode;\n const isFolderSyncDisabled = !isLocalMode;\n\n if (isUsersDisabled && activeTab === 'users') {\n activeTab = 'company';\n }\n if (isPortalDisabled && activeTab === 'portal') {\n activeTab = 'company';\n }\n if (isPortalDisabled && activeTab === 'portal_contractor') {\n activeTab = 'company';\n }\n if (isFolderSyncDisabled && activeTab === 'folder_sync') {\n activeTab = 'company';\n }\n \n const isAIAssistantDisabled = isLocalMode;\n if (isAIAssistantDisabled && activeTab === 'ai_assistant') {\n activeTab = 'company';\n }\n\n function getCategoryForTab(tab) {\n if (['company', 'portal', 'portal_contractor', 'folder_sync', 'ai_assistant', 'system'].includes(tab)) return 'general';\n if (['templates_forms', 'invoices_quotes'].includes(tab)) return 'workflow';\n if (['users', 'suppliers'].includes(tab)) return 'people';\n if (['materials', 'cost_centers', 'tax'].includes(tab)) return 'resources';\n return 'general';\n }\n\n let activeCategory = getCategoryForTab(activeTab);\n\n const openMigrationModal = () => {\n const modalContent = document.createElement('div');\n modalContent.innerHTML = `\n <form id=\"convert-cloud-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Cloud Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a local/offline account. All data will be migrated to the secure cloud database.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"migrate-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"Company Name\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"migrate-admin-name\" required placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Phone Number</label>\n <input class=\"form-input\" id=\"migrate-admin-phone\" required placeholder=\"e.g. 0412 345 678\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Email Address (Username)</label>\n <input class=\"form-input\" type=\"email\" id=\"migrate-admin-email\" required placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Password</label>\n <input class=\"form-input\" type=\"password\" id=\"migrate-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"migration-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"migration-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-migrate-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-migrate-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" id=\"submit-icon\" style=\"font-size:18px;\">cloud_done</span>\n <span id=\"submit-text\">Register & Start Migration</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: 'Register & Migrate to Cloud',\n content: modalContent,\n size: 'modal-md'\n });\n\n modalContent.querySelector('#btn-migrate-cancel').addEventListener('click', close);\n\n const form = modalContent.querySelector('#convert-cloud-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n\n const errorEl = modalContent.querySelector('#migration-error');\n const errorTextEl = modalContent.querySelector('#migration-error-text');\n const submitBtn = modalContent.querySelector('#btn-migrate-submit');\n const cancelBtn = modalContent.querySelector('#btn-migrate-cancel');\n const submitText = modalContent.querySelector('#submit-text');\n const submitIcon = modalContent.querySelector('#submit-icon');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n submitText.textContent = 'Migrating to Cloud...';\n submitIcon.className = 'material-icons-outlined spinner';\n submitIcon.textContent = 'sync';\n submitIcon.style.animation = 'spin 1s linear infinite';\n\n const companyName = modalContent.querySelector('#migrate-company-name').value.trim();\n const adminName = modalContent.querySelector('#migrate-admin-name').value.trim();\n const adminPhone = modalContent.querySelector('#migrate-admin-phone').value.trim();\n const email = modalContent.querySelector('#migrate-admin-email').value.trim();\n const password = modalContent.querySelector('#migrate-admin-password').value;\n\n try {\n const settings = store.getSettings();\n settings.name = companyName;\n await store.saveSettings(settings);\n\n const { supabase } = await import('../utils/supabase.js');\n\n const { data: authData, error: authErr } = await supabase.auth.signUp({\n email,\n password,\n options: {\n data: {\n name: adminName,\n phone: adminPhone\n }\n }\n });\n if (authErr) throw authErr;\n\n if (!authData.user) {\n throw new Error('Verification required or signup was blocked. Check your email inbox.');\n }\n\n const { data: companyId, error: rpcError } = await supabase.rpc('create_company_and_admin', {\n user_id: authData.user.id,\n company_name: companyName,\n admin_name: adminName,\n admin_phone: adminPhone\n });\n if (rpcError) throw rpcError;\n\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n await store.migrateLocalToCloud(companyId, authData.user.id);\n\n if (activeAccountId) {\n const localAccountsKey = 'relay_accounts';\n let localAccounts = [];\n try {\n const stored = localStorage.getItem(localAccountsKey);\n if (stored) {\n localAccounts = JSON.parse(stored);\n }\n } catch (e) {\n console.error('Error reading local accounts:', e);\n }\n localAccounts = localAccounts.filter(a => a.id !== activeAccountId);\n localStorage.setItem(localAccountsKey, JSON.stringify(localAccounts));\n\n store.deleteLocalAccountData(activeAccountId);\n }\n\n const { data: profile, error: profileErr } = await supabase\n .from('profiles')\n .select('*')\n .eq('id', authData.user.id)\n .single();\n if (profileErr) throw profileErr;\n\n const user = {\n id: profile.id,\n companyId: profile.company_id,\n name: profile.name,\n role: profile.role,\n userTypeName: 'Admin',\n userTypeId: `${profile.company_id}_ut_admin`,\n color: profile.color || '#FF5C00',\n theme: 'light'\n };\n\n localStorage.setItem('currentUser', JSON.stringify(user));\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Migration completed successfully! Redirecting...', 'success');\n close();\n\n setTimeout(() => {\n window.location.hash = '#/';\n window.location.reload();\n }, 1500);\n\n } catch (err) {\n console.error('Migration failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during migration.';\n errorEl.style.display = 'flex';\n\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n submitText.textContent = 'Register & Start Migration';\n submitIcon.className = 'material-icons-outlined';\n submitIcon.textContent = 'cloud_done';\n submitIcon.style.animation = '';\n }\n });\n };\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n const categories = [\n {\n id: 'general',\n label: 'General',\n icon: 'settings',\n tabs: [\n { id: 'company', label: 'Company Profile' },\n { id: 'portal', label: 'Customer Portal', disabled: isPortalDisabled, tooltip: 'Requires Cloud Account' },\n { id: 'portal_contractor', label: 'Contractor Portal', disabled: isPortalDisabled, tooltip: 'Requires Cloud Account' },\n { id: 'folder_sync', label: 'Folder Sync', disabled: isFolderSyncDisabled, tooltip: 'Requires Local Folder Storage' },\n { id: 'ai_assistant', label: 'AI Assistant', disabled: isAIAssistantDisabled, tooltip: 'Requires Cloud Account' },\n { id: 'system', label: 'System Options' }\n ]\n },\n {\n id: 'workflow',\n label: 'Workflow',\n icon: 'account_tree',\n tabs: [\n { id: 'templates_forms', label: 'Templates & Forms' },\n { id: 'invoices_quotes', label: 'Quotes & Invoices' }\n ]\n },\n {\n id: 'people',\n label: 'People',\n icon: 'groups',\n tabs: [\n { id: 'users', label: 'Users & Permissions', disabled: isUsersDisabled, tooltip: 'Requires Cloud Account or Local Server' },\n { id: 'suppliers', label: 'Suppliers Configuration' }\n ]\n },\n {\n id: 'resources',\n label: 'Resources',\n icon: 'widgets',\n tabs: [\n { id: 'materials', label: 'Materials & Kits' },\n { id: 'cost_centers', label: 'Cost Centers & Xero' },\n { id: 'tax', label: 'Tax & Labor Rates' }\n ]\n }\n ];\n\n function render() {\n const categoryTabsHtml = categories.map(cat => {\n const isActive = activeCategory === cat.id;\n return `\n <button class=\"settings-category-btn ${isActive ? 'active' : ''}\" data-category=\"${cat.id}\">\n <span class=\"material-icons-outlined\">${cat.icon}</span>\n <span>${cat.label}</span>\n </button>\n `;\n }).join('');\n\n const activeCatData = categories.find(cat => cat.id === activeCategory) || categories[0];\n const subTabsHtml = activeCatData.tabs.map(tab => {\n const isActive = activeTab === tab.id;\n const isDisabled = tab.disabled;\n const tooltipAttr = isDisabled ? `data-tooltip=\"${tab.tooltip}\" data-tooltip-pos=\"top\"` : '';\n return `\n <button class=\"settings-tab-btn ${isActive ? 'active' : ''} ${isDisabled ? 'disabled-local' : ''}\" data-tab=\"${tab.id}\" ${tooltipAttr}>\n ${tab.label}\n </button>\n `;\n }).join('');\n\n container.innerHTML = `\n <style>\n .settings-nav-categories {\n display: flex;\n gap: 8px;\n border-bottom: 2px solid var(--border-color);\n padding-bottom: 12px;\n margin-bottom: 20px;\n }\n .settings-category-btn {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 10px 20px;\n font-size: 15px;\n font-weight: 600;\n color: var(--text-secondary);\n background: transparent;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n .settings-category-btn .material-icons-outlined {\n font-size: 20px;\n transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n .settings-category-btn:hover {\n color: var(--text-primary);\n background: var(--bg-color);\n }\n .settings-category-btn:hover .material-icons-outlined {\n transform: scale(1.08);\n }\n .settings-category-btn.active {\n color: white;\n background: var(--color-primary);\n box-shadow: 0 4px 14px rgba(255, 92, 0, 0.25);\n transform: translateY(-1px);\n }\n \n .settings-nav-tabs {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-bottom: 24px;\n background: var(--bg-color);\n padding: 6px;\n border-radius: 10px;\n border: 1px solid var(--border-color);\n animation: subtabs-slide-in 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);\n }\n @keyframes subtabs-slide-in {\n from {\n opacity: 0;\n transform: translateX(-10px);\n }\n to {\n opacity: 1;\n transform: translateX(0);\n }\n }\n .settings-tab-btn {\n padding: 8px 16px;\n font-size: var(--font-size-sm);\n font-weight: 500;\n color: var(--text-secondary);\n background: transparent;\n border: none;\n border-radius: 20px;\n cursor: pointer;\n transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n }\n .settings-tab-btn:hover:not(.disabled-local) {\n color: var(--text-primary);\n background: rgba(128, 128, 128, 0.08);\n }\n .settings-tab-btn.active {\n color: var(--color-primary);\n background: var(--content-bg);\n font-weight: 600;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);\n transform: scale(1.02);\n }\n \n #settings-content {\n animation: settings-fade-in 0.28s cubic-bezier(0.4, 0, 0.2, 1);\n }\n @keyframes settings-fade-in {\n from {\n opacity: 0;\n transform: translateY(6px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n }\n </style>\n\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"settings-nav-categories\">\n ${categoryTabsHtml}\n </div>\n\n <div class=\"settings-nav-tabs\">\n ${subTabsHtml}\n </div>\n\n <div id=\"settings-content\" style=\"padding-top:var(--space-sm)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.settings-category-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n const catId = btn.dataset.category;\n if (activeCategory === catId) return;\n\n activeCategory = catId;\n const catData = categories.find(c => c.id === catId);\n const firstActiveTab = catData.tabs.find(t => !t.disabled) || catData.tabs[0];\n activeTab = firstActiveTab.id;\n\n render();\n });\n });\n\n container.querySelectorAll('.settings-tab-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n if (btn.classList.contains('disabled-local')) return;\n activeTab = btn.dataset.tab;\n render();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'suppliers') {\n renderSuppliersSettings(tc);\n return;\n }\n\n if (activeTab === 'folder_sync') {\n renderFolderSyncTab(tc);\n return;\n }\n\n if (activeTab === 'ai_assistant') {\n renderAIAssistantTab(tc);\n return;\n }\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'cost_centers') {\n renderCostCentersTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n\n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Standard Logo (Large) using Canvas to ~600x300 max bounding box\n compressImage(re.target.result, 600, 300).then((compressed) => {\n pendingLogo = compressed;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Small/Shrunk Logo using Canvas to ~200x100 max bounding box\n compressImage(re.target.result, 200, 100).then((compressed) => {\n pendingLogoSmall = compressed;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n\n\n tc.querySelector('#btn-save-company').addEventListener('click', async () => {\n const saveBtn = tc.querySelector('#btn-save-company');\n const originalHtml = saveBtn.innerHTML;\n saveBtn.disabled = true;\n saveBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving Changes...';\n\n try {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n await store.saveSettings(settings);\n showToast('Company information saved successfully to database', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n render();\n } catch (err) {\n console.error('Error saving company profile settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n saveBtn.disabled = false;\n saveBtn.innerHTML = originalHtml;\n }\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n const pendingResets = store.getAll('passwordResetRequests') || [];\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div style=\"background:rgba(59, 130, 246, 0.1); border-left:4px solid #3b82f6; padding:12px 16px; margin-bottom:var(--space-md); border-radius:4px; font-size:13px; color:#f8fafc; display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:#3b82f6; font-size:18px;\">info</span>\n <span>Your company login code is <strong style=\"color:#60a5fa\">${companySlug}</strong>. Technicians log in using <strong style=\"color:#60a5fa\">username@${companySlug}</strong>.</span>\n </span>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Username</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n ${ut?.id !== 'ut_admin' ? `\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n ` : ''}\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\">\n <h4 style=\"margin:0\">Pending Password Reset Requests</h4>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Username/Email</th>\n <th>Requested On</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:24px\">No pending reset requests</td></tr>' : ''}\n ${pendingResets.filter(r => r.status === 'pending').map(r => {\n const tech = techs.find(t => t.id === r.technician_id) || {};\n const requestedAt = r.requested_at ? new Date(r.requested_at).toLocaleString() : 'Unknown';\n return `\n <tr>\n <td class=\"font-medium\">${tech.name || 'Unknown'}</td>\n <td class=\"text-secondary\">${r.employee_id || tech.username || ''}</td>\n <td class=\"text-tertiary\">${requestedAt}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-approve-reset\" data-id=\"${r.id}\" data-tech-id=\"${tech.id}\">Approve &amp; Reset</button>\n <button class=\"btn btn-sm btn-ghost text-danger btn-deny-reset\" data-id=\"${r.id}\">Deny</button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n ${isLocalMode ? '' : `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `}\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => {\n openUserModal();\n });\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-approve-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const techId = e.currentTarget.dataset.techId;\n const t = store.getById('technicians', techId);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <p>Approve password reset request for <strong>${t.name}</strong>? Please enter their new password below:</p>\n <div class=\"form-group\" style=\"margin-top:12px\">\n <label class=\"form-label\">New Password</label>\n <input type=\"password\" id=\"admin-reset-pwd-input\" class=\"form-input\" placeholder=\"Min. 6 characters\" minlength=\"6\" autofocus />\n </div>\n `;\n showModal({\n title: 'Approve Password Reset',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reset Password', className: 'btn-primary', onClick: c => {\n const pwdInput = document.getElementById('admin-reset-pwd-input');\n const newPwd = pwdInput ? pwdInput.value : '';\n if (!newPwd || newPwd.length < 6) {\n showToast('Password must be at least 6 characters.', 'error');\n return;\n }\n \n // Update technician password\n store.update('technicians', techId, { password: newPwd });\n \n // Approve the request\n store.update('passwordResetRequests', id, { status: 'approved', updated_at: new Date().toISOString() });\n \n showToast(`Password updated for ${t.name}`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-deny-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n store.update('passwordResetRequests', id, { status: 'denied', updated_at: new Date().toISOString() });\n showToast('Password reset request denied', 'info');\n renderContent();\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n let currentJobTypes = settings.jobTypes ? [...settings.jobTypes] : ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'];\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg)\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax Rates</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\" style=\"display:flex; flex-direction:column; gap:8px\">\n <label class=\"form-label\" style=\"display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:0\">\n <input type=\"checkbox\" id=\"tax-enabled\" style=\"width:16px; height:16px; margin:0\" ${settings.taxEnabled !== false ? 'checked' : ''} />\n Enable GST / Sales Tax\n </label>\n <div style=\"display:${settings.taxEnabled !== false ? 'flex' : 'none'}; align-items:center; gap:8px; margin-top:4px\" id=\"tax-rate-container\">\n <input class=\"form-input\" id=\"tax-rate\" type=\"number\" value=\"${settings.taxRate !== undefined ? settings.taxRate : 10}\" style=\"width:100px\" min=\"0\" max=\"100\" step=\"0.1\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" id=\"job-types-card\">\n <div class=\"card-header\"><h4>Job Types</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:12px\">Define the types of jobs your team performs (e.g. Electrical, Plumbing). These appear in job forms and rate mapping.</p>\n <div id=\"job-types-list\" style=\"display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px;\">\n ${currentJobTypes.map(type => `\n <span class=\"tag-pill tag-pill-active\" style=\"display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:100px; font-weight:500; font-size:13px; background:var(--color-primary-light); color:var(--color-primary-dark)\">\n <span>${escapeHTML(type)}</span>\n <span class=\"material-icons-outlined delete-job-type-btn\" data-type=\"${escapeHTML(type)}\" style=\"font-size:16px; cursor:pointer; opacity:0.7; transition:opacity 0.2s\">close</span>\n </span>\n `).join('')}\n </div>\n <div style=\"display:flex; gap:8px;\">\n <input class=\"form-input\" id=\"new-job-type-input\" type=\"text\" placeholder=\"e.g. Renovation\" style=\"flex:1\" />\n <button class=\"btn btn-secondary\" id=\"add-job-type-btn\" style=\"display:flex; align-items:center; gap:4px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${escapeHTML(rate.name)}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${escapeHTML(rate.description || '')}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => {\n const active = applicable.includes(day);\n return `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${active ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'};background:${active ? 'var(--color-primary-light)' : 'transparent'};color:${active ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `}).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // Toggle tax rate container visibility\n tc.querySelector('#tax-enabled')?.addEventListener('change', (e) => {\n const container = tc.querySelector('#tax-rate-container');\n if (container) {\n container.style.display = e.target.checked ? 'flex' : 'none';\n }\n });\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Job Types Management Event Listeners ----\n const addJobTypeBtn = tc.querySelector('#add-job-type-btn');\n const newJobTypeInput = tc.querySelector('#new-job-type-input');\n const jobTypesList = tc.querySelector('#job-types-list');\n\n function updateJobTypesUI() {\n jobTypesList.innerHTML = currentJobTypes.map(type => `\n <span class=\"tag-pill tag-pill-active\" style=\"display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:100px; font-weight:500; font-size:13px; background:var(--color-primary-light); color:var(--color-primary-dark)\">\n <span>${escapeHTML(type)}</span>\n <span class=\"material-icons-outlined delete-job-type-btn\" data-type=\"${escapeHTML(type)}\" style=\"font-size:16px; cursor:pointer; opacity:0.7; transition:opacity 0.2s\">close</span>\n </span>\n `).join('');\n }\n\n addJobTypeBtn?.addEventListener('click', () => {\n const val = newJobTypeInput.value.trim();\n if (!val) {\n showToast('Please enter a job type name', 'warning');\n return;\n }\n if (currentJobTypes.includes(val)) {\n showToast('This job type already exists', 'warning');\n return;\n }\n currentJobTypes.push(val);\n newJobTypeInput.value = '';\n updateJobTypesUI();\n });\n\n tc.querySelector('#job-types-card')?.addEventListener('click', (e) => {\n const btn = e.target.closest('.delete-job-type-btn');\n if (btn) {\n const typeToDelete = btn.dataset.type;\n currentJobTypes = currentJobTypes.filter(t => t !== typeToDelete);\n updateJobTypesUI();\n }\n });\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#save-tax-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const taxEnabled = tc.querySelector('#tax-enabled').checked;\n const taxRate = parseFloat(tc.querySelector('#tax-rate').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.taxEnabled = taxEnabled;\n settings.taxRate = taxRate;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n settings.jobTypes = currentJobTypes;\n\n await store.saveSettings(settings);\n showToast('Financial and Rate settings saved successfully', 'success');\n renderContent();\n } catch (err) {\n console.error('Error saving tax and rate settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-portal-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n await store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving customer portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'portal_contractor') {\n const s = store.getSettings();\n const portalEnabled = s.enableContractorPortal !== false;\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Contractor Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"contractor-portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('contractor-portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Contractor Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any subcontractor attempting to load their portal token will see an access deactivated notice.</span>\n </div>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-contractor-settings\" data-tooltip=\"Save subcontractor portal access rules\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Contractor Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-contractor-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-contractor-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableContractorPortal = tc.querySelector('#contractor-portal-enable').checked;\n\n await store.saveSettings(settings);\n showToast('Contractor portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving contractor portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'system') {\n\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n const currentTheme = (currentUser && currentUser.id) ? (currentUser.theme || localStorage.getItem(`simpro_theme_${currentUser.id}`) || 'light') : 'light';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Left Column -->\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg)\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n </p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n ${currentUser.role === 'admin' ? `\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed a complete demonstration dataset\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed Demonstration Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n <button class=\"btn btn-danger\" id=\"btn-delete-company\" data-tooltip=\"Permanently delete the entire company profile and all data\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; background:var(--color-danger); border-color:var(--color-danger); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span> Delete Company Profile\n </button>\n\n ${isLocalMode ? `\n <div style=\"margin-top:16px; padding-top:12px; border-top:1px dashed rgba(220, 38, 38, 0.2)\">\n <h6 style=\"color:var(--color-warning); margin-bottom:8px; font-weight:600; display:flex; align-items:center; gap:4px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">warning</span> Irreversible Profile Conversions\n </h6>\n <p style=\"font-size:11px; color:var(--text-secondary); margin-bottom:12px; line-height:1.3;\">\n Convert your deployment type. These transitions are permanent and one-way.\n </p>\n ${localDeploymentType === 'single_user' ? `\n <button class=\"btn\" id=\"btn-convert-multiuser\" style=\"width:100%; justify-content:center; margin-bottom:12px; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">lan</span> Convert to Multi-User Local Network Sync\n </button>\n ` : ''}\n <button class=\"btn\" id=\"btn-convert-cloud-action\" style=\"width:100%; justify-content:center; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">cloud_upload</span> Convert to Cloud Sync\n </button>\n </div>\n ` : ''}\n </div>\n ` : '<div style=\"font-size:12.5px; color:var(--text-tertiary)\">Administrative actions are restricted to company administrators.</div>'}\n </div>\n </div>\n </div>\n\n <!-- Local Data Backup -->\n ${!isLocalMode && currentUser.role === 'admin' ? `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary)\">backup</span>\n <h4 style=\"margin:0;\">Local Data Backup</h4>\n </div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:12px;\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm); line-height:1.4; margin:0;\">\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n </p>\n <div id=\"backup-status-container\"></div>\n </div>\n </div>\n ` : ''}\n </div>\n\n <!-- Right Column -->\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">System Color Theme</label>\n <select class=\"form-select\" id=\"system-theme-select\" style=\"width:100%\">\n ${Object.entries(THEMES).map(([key, val]) => `\n <option value=\"${key}\" ${currentTheme === key ? 'selected' : ''}>${val.name}</option>\n `).join('')}\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Select a color profile and visual background effect for your user interface.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', async (e) => {\n const select = e.target;\n select.disabled = true;\n try {\n const settings = store.getSettings();\n settings.tooltipPreference = select.value;\n await store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved successfully', 'success');\n } catch (err) {\n console.error('Error saving tooltip preference:', err);\n showToast('Failed to save preferences: ' + (err.message || err), 'error');\n } finally {\n select.disabled = false;\n }\n });\n\n tc.querySelector('#system-theme-select')?.addEventListener('change', (e) => {\n applyTheme(e.target.value, true);\n showToast('System theme updated successfully', 'success');\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a complete demonstration dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load a complete, realistic demonstration dataset representing an active trade business (5 customers, 5 quotes/jobs per customer, 5 assets per customer, 5 materials, 5 users/timesheets, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for testing and walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed Demonstration Data\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Demo Data\",\n className: \"btn-primary\",\n onClick: async (close) => {\n close();\n showToast('Seeding demonstration data...', 'info');\n await seedMinimalData();\n showToast('Demonstration data seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-delete-company')?.addEventListener('click', () => {\n // Modal Warning 1 of 2\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">warning</span>\n <span>You are about to permanently delete the entire company profile for <strong>${escapeHTML(store.getSettings().name || '')}</strong> and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).</span>\n </div>\n <p style=\"font-size:12.5px; color:var(--text-secondary); margin-bottom:12px\">\n This action is final, irreversible, and cannot be undone under any circumstances.\n </p>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Do you want to proceed to password confirmation?</p>\n `;\n\n showModal({\n title: \"Delete Company Profile - Step 1 of 2\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (closeModal) => closeModal()\n },\n {\n label: \"Proceed to Confirm\",\n className: \"btn-danger\",\n onClick: (closeModal) => {\n closeModal();\n \n // Modal Warning 2 of 2\n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <form id=\"delete-company-confirm-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <p style=\"margin-bottom:8px; font-weight:600; color:var(--color-danger)\">FINAL CONFIRMATION (2 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">gavel</span>\n <span>To authorize the permanent destruction of this company, you must enter the Administrator password.</span>\n </div>\n\n <div class=\"form-group\" style=\"margin-top:12px;\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Password</label>\n <input class=\"form-input\" type=\"password\" id=\"delete-confirm-password\" required placeholder=\"Enter admin password to proceed\" />\n </div>\n\n <div id=\"delete-confirm-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"delete-confirm-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-delete-abort\">Abort Deletion</button>\n <button type=\"submit\" class=\"btn btn-danger\" id=\"btn-delete-confirm-submit\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span>\n <span>Permanently Delete Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close: close2 } = showModal({\n title: \"⚠️ Confirm Company Deletion - Step 2 of 2\",\n content: content2,\n size: \"modal-md\"\n });\n\n content2.querySelector('#btn-delete-abort').addEventListener('click', close2);\n\n const form2 = content2.querySelector('#delete-company-confirm-form');\n form2.addEventListener('submit', async (ev) => {\n ev.preventDefault();\n\n const errorEl = content2.querySelector('#delete-confirm-error');\n const errorTextEl = content2.querySelector('#delete-confirm-error-text');\n const submitBtn = content2.querySelector('#btn-delete-confirm-submit');\n const abortBtn = content2.querySelector('#btn-delete-abort');\n const passwordInput = content2.querySelector('#delete-confirm-password').value;\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n abortBtn.disabled = true;\n const origSubmitText = submitBtn.innerHTML;\n submitBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Deleting...';\n\n try {\n let passwordVerified = false;\n\n if (isLocalMode) {\n if (localDeploymentType === 'single_user') {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acct = storedAccounts.find(a => a.id === activeAccountId);\n if (acct && acct.hasPassword) {\n const hashedInput = await hashPassword(passwordInput);\n if (hashedInput === acct.passwordHash) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n // No password set\n passwordVerified = true;\n }\n } else {\n // local_multiuser\n const techs = store.getAll('technicians') || [];\n const currentTech = techs.find(t => t.id === currentUser.id);\n const expectedPassword = currentTech ? currentTech.password : '123456';\n if (passwordInput === expectedPassword) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n }\n } else {\n // Cloud mode\n const { supabase } = await import('../utils/supabase.js');\n const { data: { user } } = await supabase.auth.getUser();\n if (user && user.email) {\n const { error } = await supabase.auth.signInWithPassword({\n email: user.email,\n password: passwordInput\n });\n if (!error) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n throw new Error('Could not retrieve logged-in cloud user credentials.');\n }\n }\n\n if (passwordVerified) {\n // 1. If cloud mode, delete the company row in Supabase\n if (!isLocalMode) {\n const { supabase } = await import('../utils/supabase.js');\n const { error: deleteErr } = await supabase.from('companies').delete().eq('id', store.companyId);\n if (deleteErr) {\n throw new Error('Failed to delete cloud company: ' + deleteErr.message);\n }\n }\n\n // 2. Trigger store.clearAll()\n store.clearAll();\n\n // 3. Clear local account launcher keys if local mode\n if (isLocalMode) {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const updatedAccounts = storedAccounts.filter(a => a.id !== activeAccountId);\n await storageSet('relay_accounts', updatedAccounts);\n }\n }\n\n // 4. Remove session user & active account\n localStorage.removeItem('currentUser');\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Company profile deleted successfully.', 'success');\n close2();\n\n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n } catch (err) {\n console.error('Company deletion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during verification.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n abortBtn.disabled = false;\n submitBtn.innerHTML = origSubmitText;\n }\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-convert-multiuser')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n\n const techs = store.getAll('technicians') || [];\n const companyId = store.companyId || '';\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const existingAdmin = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n const currentTechName = existingAdmin ? existingAdmin.name : (currentUser.name && currentUser.name !== 'Local Admin' ? currentUser.name : '');\n const currentTechUsernameOrEmail = existingAdmin ? (existingAdmin.email || existingAdmin.username || '') : '';\n\n content.innerHTML = `\n <form id=\"convert-multiuser-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Multi-User Local Network Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a single-user Local Admin profile.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"multiuser-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"e.g. Apex Power Services\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"multiuser-admin-name\" required value=\"${escapeHTML(currentTechName)}\" placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Email Address (Required)</label>\n <input class=\"form-input\" type=\"email\" id=\"multiuser-admin-email\" required value=\"${escapeHTML(currentTechUsernameOrEmail)}\" placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Admin Password</label>\n <input class=\"form-input\" type=\"password\" id=\"multiuser-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"multiuser-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"multiuser-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-multiuser-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-multiuser-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">lan</span>\n <span>Convert Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: \"Convert to Multi-User Local Sync\",\n content: content,\n size: \"modal-md\"\n });\n\n content.querySelector('#btn-multiuser-cancel').addEventListener('click', close);\n\n const form = content.querySelector('#convert-multiuser-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n const errorEl = content.querySelector('#multiuser-error');\n const errorTextEl = content.querySelector('#multiuser-error-text');\n const submitBtn = content.querySelector('#btn-multiuser-submit');\n const cancelBtn = content.querySelector('#btn-multiuser-cancel');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n\n const businessName = content.querySelector('#multiuser-company-name').value.trim();\n const adminName = content.querySelector('#multiuser-admin-name').value.trim();\n const adminEmail = content.querySelector('#multiuser-admin-email').value.trim();\n const adminPassword = content.querySelector('#multiuser-admin-password').value;\n\n try {\n if (!businessName || !adminName || !adminEmail || !adminPassword) {\n throw new Error('All fields are required.');\n }\n if (adminPassword.length < 6) {\n throw new Error('Password must be at least 6 characters.');\n }\n\n // 1. Update settings\n const settings = store.getSettings();\n settings.name = businessName;\n settings.localDeploymentType = 'multi_user';\n await store.saveSettings(settings);\n\n // 2. Update launcher accounts businessName\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acctIndex = storedAccounts.findIndex(a => a.id === activeAccountId);\n if (acctIndex !== -1) {\n storedAccounts[acctIndex].businessName = businessName;\n await storageSet('relay_accounts', storedAccounts);\n }\n }\n\n // 3. Create or update admin user in technicians\n const companyId = store.companyId;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const techs = store.getAll('technicians') || [];\n let adminTech = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n if (!adminTech) {\n adminTech = {\n id: `${companyId}_tech_admin_created`,\n role: 'Admin',\n color: '#FF5C00',\n userTypeId: adminTypeId,\n payRate: 100.00,\n phone: ''\n };\n techs.push(adminTech);\n }\n\n adminTech.name = adminName;\n adminTech.username = adminEmail.split('@')[0];\n adminTech.email = adminEmail;\n adminTech.password = adminPassword;\n\n store.save('technicians', techs);\n\n // 4. Update currentUser session in localStorage\n const localUser = {\n id: adminTech.id,\n companyId: companyId,\n name: adminTech.name,\n role: 'admin',\n userTypeName: 'Admin',\n userTypeId: adminTech.userTypeId,\n color: adminTech.color || '#FF5C00',\n theme: 'light'\n };\n localStorage.setItem('currentUser', JSON.stringify(localUser));\n\n showToast('Converted to Multi-User Local Network Sync. Reloading...', 'success');\n close();\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n setTimeout(() => window.location.reload(), 1200);\n } catch (err) {\n console.error('Conversion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during conversion.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n }\n });\n });\n\n tc.querySelector('#btn-convert-cloud-action')?.addEventListener('click', () => {\n openMigrationModal();\n });\n\n const renderBackupStatus = () => {\n const bsc = tc.querySelector('#backup-status-container');\n if (!bsc) return;\n\n const hasHandle = !!store.backupDirHandle;\n const isPermissionGranted = store.backupDirPermissionGranted;\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const formattedLastBackup = lastBackup ? new Date(lastBackup).toLocaleString() : 'Never';\n const isSupported = typeof window !== 'undefined' && window.showDirectoryPicker;\n\n if (!isSupported) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-danger); line-height:1.4;\">\n <strong style=\"display:block; margin-bottom:4px;\">Browser Local Folder Access Unsupported</strong>\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n </div>\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:12px; border-radius:6px; margin-bottom:12px; color:var(--text-secondary); display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary);\">folder_off</span>\n <div style=\"font-size:12.5px;\">No backup folder configured.</div>\n </div>\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">folder</span> Choose Backup Folder...\n </button>\n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-warning); line-height:1.4; margin-bottom:12px;\">\n <strong>Permission Required</strong><br/>\n Access permission to <strong>${escapeHTML(store.backupDirHandle.name)}</strong> has expired. Please re-authorize.\n </div>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-warning\" id=\"btn-backup-auth\" style=\"flex:1; justify-content:center;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize & Backup\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n `;\n } else {\n bsc.innerHTML = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-success); line-height:1.4; margin-bottom:12px;\">\n <strong>Backup Configured</strong><br/>\n Folder: <strong>${escapeHTML(store.backupDirHandle.name)}</strong><br/>\n Last Backup: <strong>${formattedLastBackup}</strong>\n </div>\n <div style=\"display:flex; flex-direction:column; gap:8px;\">\n <button class=\"btn btn-primary\" id=\"btn-backup-now\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">backup</span> Backup Now\n </button>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"flex:1; justify-content:center; border:1px solid var(--border-color);\">\n <span class=\"material-icons-outlined\">folder</span> Change Folder...\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n </div>\n `;\n }\n\n // Attach event listeners for backup buttons inside container\n bsc.querySelector('#btn-backup-pick')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setBackupDirectory(handle);\n showToast('Backup directory selected successfully.', 'success');\n showToast('Running initial backup...', 'info');\n await store.backupToFolder(handle);\n showToast('Backup completed successfully!', 'success');\n renderBackupStatus();\n } catch (err) {\n console.error('Failed to select backup directory:', err);\n if (err.name !== 'AbortError') {\n showToast('Failed to configure backup: ' + err.message, 'error');\n }\n }\n });\n\n bsc.querySelector('#btn-backup-auth')?.addEventListener('click', async () => {\n const granted = await store.verifyBackupDirPermission(true);\n if (granted) {\n showToast('Permission re-authorized. Backing up...', 'info');\n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n showToast('Backup failed: ' + err.message, 'error');\n }\n } else {\n showToast('Failed to acquire write permission.', 'error');\n }\n renderBackupStatus();\n });\n\n bsc.querySelector('#btn-backup-now')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Backing up...';\n \n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Backup failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n renderBackupStatus();\n }\n });\n\n bsc.querySelector('#btn-backup-disconnect')?.addEventListener('click', async () => {\n await store.setBackupDirectory(null);\n showToast('Backup folder disconnected.', 'info');\n renderBackupStatus();\n });\n };\n\n if (!isLocalMode && currentUser.role === 'admin') {\n renderBackupStatus();\n }\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', username: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Username</label>\n <input class=\"form-input\" id=\"u-username\" value=\"${t.username || (t.email ? t.email.split('@')[0] : '')}\" ${editId ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''} placeholder=\"e.g. joshua\" />\n ${!editId ? `\n <div style=\"font-size: 11px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500;\">\n Company login code is: <strong style=\"color: var(--color-primary)\">${companySlug}</strong>. User will log in with <strong style=\"color: var(--color-primary)\">username@${companySlug}</strong>\n </div>\n ` : ''}\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">${editId ? 'Reset Password (leave blank to keep current)' : 'Temporary Password (assigned for worker login)'}</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"${editId ? '••••••••' : 'Min. 6 characters'}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\" ${(t.userTypeId === 'ut_admin' || (t.userTypeId && t.userTypeId.endsWith('_ut_admin'))) ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''}>\n <option value=\"\">-- Select --</option>\n ${userTypes\n .filter(ut => (!ut.id.endsWith('_ut_admin') && ut.id !== 'ut_admin') || t.userTypeId === ut.id)\n .map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value.trim();\n const username = document.getElementById('u-username').value.trim();\n const role = document.getElementById('u-role').value.trim();\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = document.getElementById('u-password')?.value || '';\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!username) { showToast('Username required', 'error'); return; }\n if (username.includes('@')) { showToast('Username cannot contain @ symbol', 'error'); return; }\n \n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (password && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n const updates = { name, username, role, userTypeId, color, payRate };\n if (password) {\n updates.password = password;\n }\n\n if (editId) {\n await store.update('technicians', editId, updates);\n } else {\n await store.create('technicians', updates);\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = async () => {\n const btn = tc.querySelector('#btn-save-materials');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n await store.saveSettings(updatedSettings);\n showToast('Material settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving material settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-00023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-00129',\n originalQuoteNumber: 'Q-00412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' },\n ballet: { name: 'Ballet Pointe', preset: 'ballet', accentColor: '#e58799', headerBg: '#5c2c36', accentTint: '#fff5f6', fontFamily: 'serif' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-company-name\" style=\"width:16px; height:16px\" ${dt.hideCompanyName ? 'checked' : ''} />\n <label for=\"theme-hide-company-name\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide company name text (use logo only)</label>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Variation</label>\n <select class=\"form-select\" id=\"theme-logo-source\" style=\"width:100%\">\n <option value=\"large\" ${dt.logoSource === 'large' ? 'selected' : ''}>Standard Logo (Large)</option>\n <option value=\"small\" ${dt.logoSource === 'small' ? 'selected' : ''}>Small / Shrunk Logo (Small)</option>\n </select>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:8px 0\"></div>\n <div class=\"form-group ${isLocalMode ? 'disabled-local' : ''}\" ${isLocalMode ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''} style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-brand-logo\" style=\"width:16px; height:16px\" ${dt.hideBrandLogo ? 'checked' : ''} ${isLocalMode ? 'disabled' : ''} />\n <label for=\"theme-hide-brand-logo\" class=\"form-label\" style=\"margin:0; cursor:${isLocalMode ? 'not-allowed' : 'pointer'}\">Remove Relay-dispatch brand logo from document footers</label>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n <div class=\"form-row\" style=\"display:flex; gap:12px\">\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Invoice Prefix</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoicePrefix\" value=\"${escapeHTML(dt.invoicePrefix || 'INV-')}\" placeholder=\"INV-\" />\n </div>\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Invoice Starting Number</label>\n <input type=\"number\" class=\"form-input text-copy-input\" data-prop=\"invoiceStartingNumber\" value=\"${dt.invoiceStartingNumber !== undefined ? dt.invoiceStartingNumber : 1}\" min=\"1\" placeholder=\"1\" />\n </div>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n <div class=\"form-row\" style=\"display:flex; gap:12px\">\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Quote Prefix</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quotePrefix\" value=\"${escapeHTML(dt.quotePrefix || 'Q-')}\" placeholder=\"Q-\" />\n </div>\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Quote Starting Number</label>\n <input type=\"number\" class=\"form-input text-copy-input\" data-prop=\"quoteStartingNumber\" value=\"${dt.quoteStartingNumber !== undefined ? dt.quoteStartingNumber : 1}\" min=\"1\" placeholder=\"1\" />\n </div>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Hide Company Name toggle\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) {\n hideCompanyNameChk.addEventListener('change', () => {\n dt.hideCompanyName = hideCompanyNameChk.checked;\n updatePreview();\n });\n }\n\n // Logo Variation toggle\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) {\n logoSourceSelect.addEventListener('change', (e) => {\n dt.logoSource = e.target.value;\n updatePreview();\n });\n }\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Hide Brand Logo toggle\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) {\n hideBrandLogoChk.addEventListener('change', () => {\n dt.hideBrandLogo = hideBrandLogoChk.checked;\n updatePreview();\n });\n }\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-theme-save');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n await store.saveSettings(freshSettings);\n showToast('Document customization settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving theme settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n hideCompanyName: false,\n hideBrandLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) hideCompanyNameChk.checked = !!dt.hideCompanyName;\n\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) logoSourceSelect.value = dt.logoSource || 'large';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePrefixInp = tc.querySelector('[data-prop=\"invoicePrefix\"]');\n if (invoicePrefixInp) invoicePrefixInp.value = dt.invoicePrefix || '';\n\n const invoiceStartingNumberInp = tc.querySelector('[data-prop=\"invoiceStartingNumber\"]');\n if (invoiceStartingNumberInp) invoiceStartingNumberInp.value = dt.invoiceStartingNumber !== undefined ? dt.invoiceStartingNumber : 1;\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const quotePrefixInp = tc.querySelector('[data-prop=\"quotePrefix\"]');\n if (quotePrefixInp) quotePrefixInp.value = dt.quotePrefix || '';\n\n const quoteStartingNumberInp = tc.querySelector('[data-prop=\"quoteStartingNumber\"]');\n if (quoteStartingNumberInp) quoteStartingNumberInp.value = dt.quoteStartingNumber !== undefined ? dt.quoteStartingNumber : 1;\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) hideBrandLogoChk.checked = !!dt.hideBrandLogo;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n const logoUrl = dt.logoSource === 'small' ? (settings.logoSmall || settings.logo) : (settings.logo || settings.logoSmall);\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.src = logoUrl || '';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // Company Name visibility\n const companyNameEl = frameDoc.querySelector('.pdf-company-name');\n if (companyNameEl) {\n companyNameEl.style.display = dt.hideCompanyName ? 'none' : 'block';\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n\n const footerBrandingEl = frameDoc.querySelector('.pdf-footer-branding');\n if (footerBrandingEl) {\n const hideBrandLogo = isLocalMode ? false : !!dt.hideBrandLogo;\n footerBrandingEl.style.display = hideBrandLogo ? 'none' : 'flex';\n }\n }\n\n renderMarkup();\n }\n\n function renderFolderSyncTab(tc) {\n const isSupported = typeof window !== 'undefined' && (\n (window.indexedDB && window.showDirectoryPicker) ||\n (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem)\n );\n\n const isCapacitor = typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem;\n\n const activeAccountId = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n const isLocalCompany = !!activeAccountId;\n\n function render() {\n console.log('[DEBUG Settings] render: isLocalCompany =', isLocalCompany, 'store.dirHandle =', store.dirHandle, 'permissionGranted =', store.folderSyncPermissionGranted);\n const isEnabled = isLocalCompany ? true : store.folderSyncEnabled;\n const isPermissionGranted = store.folderSyncPermissionGranted;\n const hasHandle = !!store.dirHandle;\n\n let statusHtml = '';\n if (!isSupported) {\n statusHtml = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:16px; border-radius:4px; color:var(--color-danger); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:8px; font-weight:600; margin-bottom:4px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Browser Directory Access Unsupported</span>\n </div>\n <p style=\"font-size:var(--font-size-sm); margin:0; line-height:1.4;\">\n Your current browser does not support local folder access. To enable direct folder synchronization, please run this app in a Chromium-based browser (Chrome, Edge, Opera, or as a compiled native app). High-capacity IndexedDB storage remains active.\n </p>\n </div>\n `;\n } else if (!isEnabled) {\n statusHtml = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:16px; border-radius:6px; color:var(--text-secondary); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px; color:var(--text-tertiary);\">folder_off</span>\n <div>\n <div style=\"font-weight:600; color:var(--text-primary); margin-bottom:2px;\">Folder Synchronization Inactive</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4;\">All data is currently stored in your browser's private IndexedDB database sandbox.</p>\n </div>\n </div>\n `;\n } else if (isCapacitor) {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">cloud_done</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Capacitor Direct Folder Sync Active</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Data is synchronizing directly to the application's native <strong>Documents/RelayDispatchData</strong> directory on your iPad/device.\n </p>\n </div>\n </div>\n `;\n } else if (!hasHandle) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">warning</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">No Directory Selected</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">Please select a directory folder on your computer to begin syncing.</p>\n </div>\n </div>\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:12px; margin-bottom:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">lock</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Access Permission Suspended</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n The browser requires re-authorization to read/write files in <strong>${escapeHTML(store.dirHandle.name)}</strong>.\n </p>\n </div>\n </div>\n <button class=\"btn btn-warning\" id=\"btn-reauthorize-dir\" style=\"margin-left:44px;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize Access\n </button>\n </div>\n `;\n } else {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">check_circle</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Folder Sync Active & Synchronized</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Active Folder: <strong>${escapeHTML(store.dirHandle.name)}</strong>. All data edits are writing dynamically.\n </p>\n </div>\n </div>\n `;\n }\n\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <!-- Folder Configuration -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Direct Directory Sync</h4></div>\n <div class=\"card-body\">\n ${statusHtml}\n\n <div class=\"form-group\" style=\"margin-bottom:var(--space-lg);\">\n <label class=\"form-label\" style=\"font-weight:600; margin-bottom:8px;\">Synchronization Toggle</label>\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\">\n <input type=\"checkbox\" id=\"toggle-folder-sync\" ${isEnabled ? 'checked' : ''} ${(!isSupported || isLocalCompany) ? 'disabled' : ''} style=\"width:20px; height:20px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\" />\n <div>\n <span style=\"font-weight:500;\">Enable Direct Local Folder Storage</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">\n ${isLocalCompany ? 'Mandatory for offline/local company profile storage.' : 'Saves all data records and attachments straight to your machine.'}\n </div>\n </div>\n </label>\n </div>\n\n ${isEnabled && isSupported && !isCapacitor ? `\n <div style=\"display:flex; gap:12px; flex-wrap:wrap; border-top:1px solid var(--border-color); padding-top:var(--space-lg);\">\n <button class=\"btn btn-secondary\" id=\"btn-pick-dir\">\n <span class=\"material-icons-outlined\">folder</span> Choose Sync Folder...\n </button>\n ${hasHandle && isPermissionGranted ? `\n <button class=\"btn btn-secondary\" id=\"btn-force-sync\" data-tooltip=\"Overwrite folder JSONs with current memory cache\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">sync</span> Sync Now\n </button>\n ${!isLocalCompany ? `\n <button class=\"btn btn-danger\" id=\"btn-disconnect-dir\">\n <span class=\"material-icons-outlined\">link_off</span> Disconnect Folder\n </button>\n ` : ''}\n ` : ''}\n </div>\n ` : ''}\n </div>\n </div>\n\n <!-- Instructions Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>How Self-Hosting Works</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p>\n By linking a local folder, you establish a <strong>serverless self-hosted data hub</strong>.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">storage</span>\n <span><strong>Readable Data:</strong> Your jobs, quotes, and company configurations are saved in <code>data/*.json</code> files.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">photo_library</span>\n <span><strong>Physical Attachments:</strong> Uploaded photos and PDF catalogs are saved as standard image/PDF files under the <code>documents/</code> subfolder.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">sync_alt</span>\n <span><strong>Cloud Backups:</strong> Select a folder synced to <strong>OneDrive, Google Drive, or Dropbox</strong> to automatically back up and sync your data to the cloud.</span>\n </div>\n </div>\n </div>\n </div>\n `;\n\n // Event Listeners\n const toggle = tc.querySelector('#toggle-folder-sync');\n toggle?.addEventListener('change', async (e) => {\n if (isLocalCompany) {\n e.preventDefault();\n toggle.checked = true;\n return;\n }\n\n if (isCapacitor) {\n await store.setLocalDirectory(e.target.checked ? {} : null);\n showToast(e.target.checked ? 'Capacitor folder sync activated' : 'Folder sync deactivated', 'success');\n render();\n return;\n }\n\n if (e.target.checked) {\n // Trigger directory picker\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory configured successfully', 'success');\n } catch (err) {\n console.error('Directory selection cancelled or failed:', err);\n toggle.checked = false;\n showToast('Folder selection cancelled', 'info');\n }\n } else {\n await store.setLocalDirectory(null);\n showToast('Direct directory synchronization disabled', 'info');\n }\n render();\n });\n\n tc.querySelector('#btn-pick-dir')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory updated successfully', 'success');\n render();\n } catch (err) {\n console.error(err);\n }\n });\n\n tc.querySelector('#btn-reauthorize-dir')?.addEventListener('click', async () => {\n const granted = await store.verifyDirPermission(true);\n if (granted) {\n showToast('Folder access re-authorized successfully', 'success');\n } else {\n showToast('Failed to acquire write permissions', 'error');\n }\n render();\n });\n\n tc.querySelector('#btn-disconnect-dir')?.addEventListener('click', async () => {\n await store.setLocalDirectory(null);\n showToast('Local folder disconnected', 'info');\n render();\n });\n\n tc.querySelector('#btn-force-sync')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n btn.disabled = true;\n showToast('Writing cache data to local directory...', 'info');\n try {\n const collections = Object.keys(store.cache);\n await Promise.all(collections.map(col => store.writeCollectionToFolder(col, store.cache[col])));\n showToast('Folder sync completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Folder sync failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n }\n });\n }\n\n render();\n }\n\n function renderAIAssistantTab(tc) {\n const s = store.getSettings();\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const ai = s.ai || {\n enabled: false,\n apiKey: '',\n endpoint: 'https://api.openai.com/v1/chat/completions',\n model: 'gpt-4o-mini',\n systemPrompt: 'You are Relay, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n };\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null') || { id: 'default' };\n const userId = currentUser.id || 'default';\n const factsheetKey = `relay_factsheet_${userId}`;\n const enabledKey = `relay_factsheet_enabled_${userId}`;\n const factsheetVal = localStorage.getItem(factsheetKey) || '';\n const memoryEnabled = localStorage.getItem(enabledKey) !== 'false';\n\n function render() {\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg);\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>AI Assistant Configuration</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n \n <div class=\"form-group\">\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:pointer;\">\n <input type=\"checkbox\" id=\"ai-enabled\" ${ai.enabled ? 'checked' : ''} style=\"width:20px; height:20px; cursor:pointer;\" />\n <div>\n <span style=\"font-weight:600;\">Enable AI Assistant</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">Replaces the basic rule-based Relay co-pilot with a smart conversational LLM.</div>\n </div>\n </label>\n </div>\n\n <div id=\"ai-fields\" style=\"display: ${ai.enabled ? 'flex' : 'none'}; flex-direction:column; gap:16px; border-top:1px solid var(--border-color); padding-top:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">API Key</label>\n <input type=\"password\" class=\"form-input\" id=\"ai-apikey\" value=\"${ai.apiKey || ''}\" placeholder=\"${isLocalMode ? 'sk-...' : 'Optional — Defaults to secure cloud API key'}\" />\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">${isLocalMode ? 'Your API key is stored locally in your browser/sync directory and shared amongst employees.' : 'Your API key is optional. If left blank, cloud operations will securely route through Supabase Edge Functions.'}</div>\n </div>\n\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">API Endpoint</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-endpoint\" value=\"${ai.endpoint || 'https://api.openai.com/v1/chat/completions'}\" placeholder=\"https://api.openai.com/v1/chat/completions\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Model Name</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-model\" value=\"${ai.model || 'gpt-4o-mini'}\" placeholder=\"gpt-4o-mini\" />\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Base System Prompt</label>\n <textarea class=\"form-input\" id=\"ai-systemprompt\" rows=\"4\" style=\"font-family:inherit; resize:vertical;\">${ai.systemPrompt || ''}</textarea>\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.</div>\n </div>\n\n <div style=\"display:flex; gap:12px; align-items:center; margin-top:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-test-ai\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">bolt</span> Test Connection\n </button>\n <span id=\"test-status\" style=\"font-size:13px; font-weight:500;\"></span>\n </div>\n <div id=\"test-error-details\" style=\"display:none; margin-top:8px; font-size:12px; color:var(--color-danger); background:var(--color-danger-bg); border-left:3px solid var(--color-danger); padding:8px; border-radius:4px; line-height:1.4;\"></div>\n </div>\n\n <div style=\"border-top:1px solid var(--border-color); padding-top:16px; display:flex; justify-content:flex-end;\">\n <button class=\"btn btn-primary\" id=\"btn-save-ai\">Save AI Config</button>\n </div>\n\n </div>\n </div>\n\n\n\n </div>\n\n <!-- Cheapest Smart AI Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>Cheapest Smart AI Guide</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p style=\"margin:0;\">\n <strong>DeepSeek</strong> is one of the most cost-efficient and high-performance language models currently available, making it perfect for lightweight co-pilot tasks.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">key</span>\n <span><strong>API Key:</strong> Get your key from <a href=\"https://platform.deepseek.com/\" target=\"_blank\" style=\"color:var(--color-primary); text-decoration:underline;\">platform.deepseek.com</a>. New accounts typically receive free trial credits.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">security</span>\n <span><strong>CORS Restrictions:</strong> Due to browser security, direct API calls to DeepSeek may be blocked. To bypass this:\n <ul style=\"margin:4px 0 0 16px; padding:0; list-style:disc; font-size:12px;\">\n <li>Route via a CORS proxy (e.g. <code>https://cors-anywhere.herokuapp.com/</code> prefix).</li>\n <li>Connect via an aggregator like <strong>OpenRouter</strong> (API Endpoint: <code>https://openrouter.ai/api/v1/chat/completions</code>).</li>\n <li>Use a local LLM runner like <strong>Ollama</strong> (API Endpoint: <code>http://localhost:11434/v1/chat/completions</code>).</li>\n </ul>\n </span>\n </div>\n </div>\n </div>\n\n </div>\n `;\n\n // Toggle display of fields based on enabled checkbox\n const enabledCheckbox = tc.querySelector('#ai-enabled');\n const fieldsContainer = tc.querySelector('#ai-fields');\n enabledCheckbox.addEventListener('change', (e) => {\n fieldsContainer.style.display = e.target.checked ? 'flex' : 'none';\n\n });\n\n\n\n // Save handler\n tc.querySelector('#btn-save-ai').addEventListener('click', () => {\n const enabled = enabledCheckbox.checked;\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const systemPrompt = tc.querySelector('#ai-systemprompt').value.trim();\n\n if (isLocalMode && enabled && !apiKey) {\n showToast('API Key is required when enabling the AI assistant.', 'error');\n return;\n }\n\n const settings = store.getSettings();\n settings.ai = {\n enabled,\n apiKey,\n endpoint,\n model,\n systemPrompt\n };\n\n store.saveSettings(settings);\n showToast('AI settings saved successfully', 'success');\n });\n\n // Test handler\n tc.querySelector('#btn-test-ai').addEventListener('click', async () => {\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const statusEl = tc.querySelector('#test-status');\n const errEl = tc.querySelector('#test-error-details');\n\n const inElectron = !!(window.electronAPI && window.electronAPI.callDeepSeek);\n const hasEnvKey = !!(import.meta.env.VITE_DEEPSEEK_API_KEY);\n\n if (!apiKey && !inElectron && !hasEnvKey) {\n showToast('Enter an API key to test.', 'error');\n return;\n }\n\n statusEl.style.color = 'var(--text-secondary)';\n statusEl.textContent = 'Testing connection...';\n errEl.style.display = 'none';\n\n const startTime = Date.now();\n try {\n if (inElectron) {\n const data = await window.electronAPI.callDeepSeek({\n messages: [{ role: 'user', content: 'respond only with the word OK' }],\n endpoint,\n model,\n apiKey // Pass typed key if any\n });\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success (via Electron IPC)! Latency: ${latency}ms (Reply: \"${reply}\")`;\n } else {\n const res = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY}`\n },\n body: JSON.stringify({\n model: model || 'deepseek-chat',\n messages: [\n { role: 'user', content: 'respond only with the word OK' }\n ],\n max_tokens: 5\n })\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`HTTP ${res.status}: ${body}`);\n }\n\n const data = await res.json();\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success! Latency: ${latency}ms (Reply: \"${reply}\")`;\n }\n } catch (err) {\n console.error('AI test connection failed:', err);\n statusEl.style.color = 'var(--color-danger)';\n statusEl.textContent = 'Test failed';\n errEl.style.display = 'block';\n\n let errMsg = err.message;\n if (err.name === 'TypeError' && err.message.includes('Failed to fetch')) {\n errMsg = 'CORS Blocked or Offline. The browser blocked this cross-origin request. Please use a CORS proxy, an API gateway like OpenRouter, or check your internet connection.';\n }\n errEl.textContent = errMsg;\n }\n });\n }\n\n render();\n }\n\n function renderCostCentersTab(tc) {\n const list = store.getAll('costCenters') || [];\n\n const renderTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:900px\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Cost Centers</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-cost-center\" style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span> Add Cost Center\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"padding-left:16px; width:120px\">Code</th>\n <th>Name</th>\n <th>Xero Mapping</th>\n <th style=\"width:120px\">Status</th>\n <th style=\"text-align:right; padding-right:16px; width:180px\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${list.length === 0 ? `\n <tr>\n <td colspan=\"5\" style=\"text-align:center; padding:32px 16px; color:var(--text-secondary)\">\n No Cost Centers configured. Click \"Add Cost Center\" to create one.\n </td>\n </tr>\n ` : list.map(cc => `\n <tr>\n <td style=\"padding-left:16px; font-weight:600\">${escapeHTML(cc.code)}</td>\n <td>${escapeHTML(cc.name)}</td>\n <td>\n ${cc.xeroSalesAccountCode || cc.xeroTrackingOptionName ? `\n <div style=\"font-size:11px; line-height:1.4\">\n ${cc.xeroSalesAccountCode ? `<div><span class=\"text-tertiary\">Sales Code:</span> <strong>${escapeHTML(cc.xeroSalesAccountCode)}</strong></div>` : ''}\n ${cc.xeroExpenseAccountCode ? `<div><span class=\"text-tertiary\">Expense Code:</span> <strong>${escapeHTML(cc.xeroExpenseAccountCode)}</strong></div>` : ''}\n ${cc.xeroTrackingOptionName ? `<div><span class=\"text-tertiary\">Tracking:</span> <strong>${escapeHTML(cc.xeroTrackingCategoryName || 'Department')}:${escapeHTML(cc.xeroTrackingOptionName)}</strong></div>` : ''}\n </div>\n ` : '<span class=\"text-tertiary\" style=\"font-size:11px\">— Unmapped —</span>'}\n </td>\n <td>\n <span class=\"badge ${cc.active ? 'badge-success' : 'badge-neutral'}\">\n ${cc.active ? 'Active' : 'Inactive'}\n </span>\n </td>\n <td style=\"text-align:right; padding-right:16px\">\n <button class=\"btn btn-secondary btn-sm btn-edit-cc\" data-id=\"${cc.id}\" style=\"margin-right:6px\">Edit</button>\n <button class=\"btn btn-outline btn-sm btn-toggle-cc\" data-id=\"${cc.id}\">\n ${cc.active ? 'Disable' : 'Enable'}\n </button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-cost-center')?.addEventListener('click', () => openCostCenterModal());\n tc.querySelectorAll('.btn-edit-cc').forEach(btn => {\n btn.addEventListener('click', (e) => openCostCenterModal(e.target.dataset.id));\n });\n tc.querySelectorAll('.btn-toggle-cc').forEach(btn => {\n btn.addEventListener('click', async (e) => {\n const id = e.target.dataset.id;\n const cc = store.getById('costCenters', id);\n if (cc) {\n const newActive = !cc.active;\n await store.update('costCenters', id, { active: newActive });\n showToast(`Cost center ${cc.name} has been ${newActive ? 'enabled' : 'disabled'}.`, 'success');\n renderCostCentersTab(tc);\n }\n });\n });\n };\n\n renderTab();\n }\n\n function openCostCenterModal(editId = null) {\n let cc = editId ? store.getById('costCenters', editId) : { \n code: '', \n name: '', \n active: true,\n xeroSalesAccountCode: '',\n xeroExpenseAccountCode: '',\n xeroTrackingCategoryName: 'Department',\n xeroTrackingOptionName: ''\n };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\" style=\"margin-bottom:16px\">\n <label class=\"form-label\" style=\"display:block; margin-bottom:6px\">Cost Center Code</label>\n <input class=\"form-input\" id=\"cc-code\" value=\"${escapeHTML(cc.code)}\" placeholder=\"e.g. ELEC\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\" style=\"margin-bottom:16px\">\n <label class=\"form-label\" style=\"display:block; margin-bottom:6px\">Cost Center Name</label>\n <input class=\"form-input\" id=\"cc-name\" value=\"${escapeHTML(cc.name)}\" placeholder=\"e.g. Electrical Services\" style=\"width:100%\" />\n </div>\n \n <fieldset style=\"border: 1px solid var(--border-color); border-radius: 6px; padding: 12px; margin-bottom: 16px; background:var(--card-bg)\">\n <legend style=\"padding: 0 6px; font-size: 11px; font-weight: 600; color: var(--color-primary); display: flex; align-items: center; gap: 4px; margin: 0\">\n <span class=\"material-icons-outlined\" style=\"font-size:15px\">sync</span> Xero Integration (Optional)\n </legend>\n <div class=\"form-row\" style=\"margin-bottom:12px; display:grid; grid-template-columns:1fr 1fr; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Sales Account Code</label>\n <input class=\"form-input\" id=\"cc-xero-sales\" value=\"${escapeHTML(cc.xeroSalesAccountCode || '')}\" placeholder=\"e.g. 200\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Expense Account Code</label>\n <input class=\"form-input\" id=\"cc-xero-expense\" value=\"${escapeHTML(cc.xeroExpenseAccountCode || '')}\" placeholder=\"e.g. 300\" style=\"width:100%\" />\n </div>\n </div>\n <div class=\"form-row\" style=\"display:grid; grid-template-columns:1fr 1fr; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Tracking Category</label>\n <input class=\"form-input\" id=\"cc-xero-category\" value=\"${escapeHTML(cc.xeroTrackingCategoryName || 'Department')}\" placeholder=\"e.g. Department\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px; margin-bottom:4px\">Tracking Option</label>\n <input class=\"form-input\" id=\"cc-xero-option\" value=\"${escapeHTML(cc.xeroTrackingOptionName || '')}\" placeholder=\"e.g. Electrical\" style=\"width:100%\" />\n </div>\n </div>\n </fieldset>\n\n <div class=\"form-group\">\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer\">\n <input type=\"checkbox\" id=\"cc-active\" ${cc.active ? 'checked' : ''} style=\"width:16px; height:16px; margin:0\" />\n <span>Active</span>\n </label>\n </div>\n `;\n\n showModal({\n title: editId ? 'Edit Cost Center' : 'Add Cost Center',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-cc', onClick: async (c) => {\n const code = document.getElementById('cc-code').value.trim().toUpperCase();\n const name = document.getElementById('cc-name').value.trim();\n const active = document.getElementById('cc-active').checked;\n const xeroSalesAccountCode = document.getElementById('cc-xero-sales').value.trim();\n const xeroExpenseAccountCode = document.getElementById('cc-xero-expense').value.trim();\n const xeroTrackingCategoryName = document.getElementById('cc-xero-category').value.trim();\n const xeroTrackingOptionName = document.getElementById('cc-xero-option').value.trim();\n\n if (!code) { showToast('Code required', 'error'); return; }\n if (!name) { showToast('Name required', 'error'); return; }\n\n try {\n const updates = { \n code, \n name, \n active,\n xeroSalesAccountCode: xeroSalesAccountCode || null,\n xeroExpenseAccountCode: xeroExpenseAccountCode || null,\n xeroTrackingCategoryName: xeroTrackingCategoryName || null,\n xeroTrackingOptionName: xeroTrackingOptionName || null\n };\n if (editId) {\n await store.update('costCenters', editId, updates);\n showToast('Cost Center updated successfully', 'success');\n } else {\n await store.create('costCenters', updates);\n showToast('Cost Center created successfully', 'success');\n }\n c();\n const tc = document.querySelector('#settings-content');\n renderCostCentersTab(tc);\n } catch (err) {\n console.error('Error saving cost center:', err);\n showToast('Failed to save cost center: ' + err.message, 'error');\n }\n }}\n ]\n });\n }\n\n function renderSuppliersSettings(tc) {\n const settings = store.getSettings();\n const categories = settings.supplierCategories || ['Electrical', 'Plumbing', 'HVAC', 'Fire Safety', 'Security', 'General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Supplier Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Define classifications/categories for your suppliers (e.g. Electrical, Plumbing, HVAC). These categories are used to group suppliers in the Suppliers directory.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"supplier-categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${escapeHTML(c)}\n <span class=\"material-icons-outlined btn-remove-supplier-cat\" data-name=\"${escapeHTML(c)}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-supplier-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-suppliers\" data-tooltip=\"Save supplier categories settings\" data-tooltip-pos=\"top\">Save Supplier Settings</button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-supplier-category').addEventListener('click', () => {\n const name = prompt('Enter supplier category name:');\n if (name) {\n const trimmed = name.trim();\n if (!trimmed) return;\n const exists = Array.from(tc.querySelectorAll('.btn-remove-supplier-cat')).some(span => span.dataset.name.toLowerCase() === trimmed.toLowerCase());\n if (exists) {\n showToast('Category already exists', 'error');\n return;\n }\n\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${escapeHTML(trimmed)}\n <span class=\"material-icons-outlined btn-remove-supplier-cat\" data-name=\"${escapeHTML(trimmed)}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#supplier-categories-container').insertBefore(btn, tc.querySelector('#btn-add-supplier-category'));\n btn.querySelector('.btn-remove-supplier-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-supplier-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-suppliers').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-suppliers');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const supplierCategories = Array.from(tc.querySelectorAll('.btn-remove-supplier-cat')).map(span => span.dataset.name);\n const updatedSettings = {\n ...settings,\n supplierCategories\n };\n await store.saveSettings(updatedSettings);\n showToast('Supplier settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving supplier settings:', err);\n showToast('Failed to save supplier settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n }\n"}, "f06374f4c6efefe115a2a42206649ab9ebae87fc": {"t": 1785754180.7377093, "n": 264323, "h": "8959d61b38a4040e", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData, seedData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\nimport { applyTheme, THEMES } from '../utils/theme.js';\nimport { storageGet, storageSet } from '../utils/tauriStore.js';\n\n// Compress uploaded images using Canvas to avoid huge Base64 data payloads\nfunction compressImage(dataUrl, maxWidth, maxHeight) {\n return new Promise((resolve) => {\n if (!dataUrl) {\n resolve(dataUrl);\n return;\n }\n // SVG vectors are already lightweight; do not compress them\n if (dataUrl.startsWith('data:image/svg+xml')) {\n resolve(dataUrl);\n return;\n }\n const img = new Image();\n img.onload = () => {\n let width = img.width;\n let height = img.height;\n\n if (width > height) {\n if (width > maxWidth) {\n height = Math.round((height * maxWidth) / width);\n width = maxWidth;\n }\n } else {\n if (height > maxHeight) {\n width = Math.round((width * maxHeight) / height);\n height = maxHeight;\n }\n }\n\n const canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n\n const ctx = canvas.getContext('2d');\n ctx.drawImage(img, 0, 0, width, height);\n\n const isPng = dataUrl.includes('image/png');\n const format = isPng ? 'image/png' : 'image/jpeg';\n const quality = isPng ? undefined : 0.85;\n\n resolve(canvas.toDataURL(format, quality));\n };\n img.onerror = () => {\n resolve(dataUrl);\n };\n img.src = dataUrl;\n });\n}\n\n// Helper to hash password using SHA-256 Web Crypto API\nasync function hashPassword(password) {\n const msgBuffer = new TextEncoder().encode(password);\n const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);\n const hashArray = Array.from(new Uint8Array(hashBuffer));\n return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');\n}\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n const localDeploymentType = settings.localDeploymentType || 'single_user';\n\n const isUsersDisabled = isLocalMode && localDeploymentType === 'single_user';\n const isPortalDisabled = isLocalMode;\n const isFolderSyncDisabled = !isLocalMode;\n\n if (isUsersDisabled && activeTab === 'users') {\n activeTab = 'company';\n }\n if (isPortalDisabled && activeTab === 'portal') {\n activeTab = 'company';\n }\n if (isFolderSyncDisabled && activeTab === 'folder_sync') {\n activeTab = 'company';\n }\n \n const isAIAssistantDisabled = isLocalMode;\n if (isAIAssistantDisabled && activeTab === 'ai_assistant') {\n activeTab = 'company';\n }\n\n const openMigrationModal = () => {\n const modalContent = document.createElement('div');\n modalContent.innerHTML = `\n <form id=\"convert-cloud-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Cloud Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a local/offline account. All data will be migrated to the secure cloud database.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"migrate-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"Company Name\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"migrate-admin-name\" required placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Phone Number</label>\n <input class=\"form-input\" id=\"migrate-admin-phone\" required placeholder=\"e.g. 0412 345 678\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Email Address (Username)</label>\n <input class=\"form-input\" type=\"email\" id=\"migrate-admin-email\" required placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Password</label>\n <input class=\"form-input\" type=\"password\" id=\"migrate-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"migration-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"migration-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-migrate-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-migrate-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" id=\"submit-icon\" style=\"font-size:18px;\">cloud_done</span>\n <span id=\"submit-text\">Register & Start Migration</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: 'Register & Migrate to Cloud',\n content: modalContent,\n size: 'modal-md'\n });\n\n modalContent.querySelector('#btn-migrate-cancel').addEventListener('click', close);\n\n const form = modalContent.querySelector('#convert-cloud-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n\n const errorEl = modalContent.querySelector('#migration-error');\n const errorTextEl = modalContent.querySelector('#migration-error-text');\n const submitBtn = modalContent.querySelector('#btn-migrate-submit');\n const cancelBtn = modalContent.querySelector('#btn-migrate-cancel');\n const submitText = modalContent.querySelector('#submit-text');\n const submitIcon = modalContent.querySelector('#submit-icon');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n submitText.textContent = 'Migrating to Cloud...';\n submitIcon.className = 'material-icons-outlined spinner';\n submitIcon.textContent = 'sync';\n submitIcon.style.animation = 'spin 1s linear infinite';\n\n const companyName = modalContent.querySelector('#migrate-company-name').value.trim();\n const adminName = modalContent.querySelector('#migrate-admin-name').value.trim();\n const adminPhone = modalContent.querySelector('#migrate-admin-phone').value.trim();\n const email = modalContent.querySelector('#migrate-admin-email').value.trim();\n const password = modalContent.querySelector('#migrate-admin-password').value;\n\n try {\n const settings = store.getSettings();\n settings.name = companyName;\n await store.saveSettings(settings);\n\n const { supabase } = await import('../utils/supabase.js');\n\n const { data: authData, error: authErr } = await supabase.auth.signUp({\n email,\n password,\n options: {\n data: {\n name: adminName,\n phone: adminPhone\n }\n }\n });\n if (authErr) throw authErr;\n\n if (!authData.user) {\n throw new Error('Verification required or signup was blocked. Check your email inbox.');\n }\n\n const { data: companyId, error: rpcError } = await supabase.rpc('create_company_and_admin', {\n user_id: authData.user.id,\n company_name: companyName,\n admin_name: adminName,\n admin_phone: adminPhone\n });\n if (rpcError) throw rpcError;\n\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n await store.migrateLocalToCloud(companyId, authData.user.id);\n\n if (activeAccountId) {\n const localAccountsKey = 'relay_accounts';\n let localAccounts = [];\n try {\n const stored = localStorage.getItem(localAccountsKey);\n if (stored) {\n localAccounts = JSON.parse(stored);\n }\n } catch (e) {\n console.error('Error reading local accounts:', e);\n }\n localAccounts = localAccounts.filter(a => a.id !== activeAccountId);\n localStorage.setItem(localAccountsKey, JSON.stringify(localAccounts));\n\n store.deleteLocalAccountData(activeAccountId);\n }\n\n const { data: profile, error: profileErr } = await supabase\n .from('profiles')\n .select('*')\n .eq('id', authData.user.id)\n .single();\n if (profileErr) throw profileErr;\n\n const user = {\n id: profile.id,\n companyId: profile.company_id,\n name: profile.name,\n role: profile.role,\n userTypeName: 'Admin',\n userTypeId: `${profile.company_id}_ut_admin`,\n color: profile.color || '#FF5C00',\n theme: 'light'\n };\n\n localStorage.setItem('currentUser', JSON.stringify(user));\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Migration completed successfully! Redirecting...', 'success');\n close();\n\n setTimeout(() => {\n window.location.hash = '#/';\n window.location.reload();\n }, 1500);\n\n } catch (err) {\n console.error('Migration failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during migration.';\n errorEl.style.display = 'flex';\n\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n submitText.textContent = 'Register & Start Migration';\n submitIcon.className = 'material-icons-outlined';\n submitIcon.textContent = 'cloud_done';\n submitIcon.style.animation = '';\n }\n });\n };\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''} ${isUsersDisabled ? 'disabled-local' : ''}\" data-tab=\"users\" ${isUsersDisabled ? 'data-tooltip=\"Requires Cloud Account or Local Server\" data-tooltip-pos=\"top\"' : ''}>Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''} ${isPortalDisabled ? 'disabled-local' : ''}\" data-tab=\"portal\" ${isPortalDisabled ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''}>Customer Portal</button>\n <button class=\"tab ${activeTab === 'invoices_quotes' ? 'active' : ''}\" data-tab=\"invoices_quotes\">Quotes &amp; Invoices</button>\n <button class=\"tab ${activeTab === 'cost_centers' ? 'active' : ''}\" data-tab=\"cost_centers\">Cost Centers</button>\n <button class=\"tab ${activeTab === 'folder_sync' ? 'active' : ''} ${isFolderSyncDisabled ? 'disabled-local' : ''}\" data-tab=\"folder_sync\" ${isFolderSyncDisabled ? 'data-tooltip=\"Requires Local Folder Storage\" data-tooltip-pos=\"top\"' : ''}>Folder Sync</button>\n <button class=\"tab ${activeTab === 'ai_assistant' ? 'active' : ''} ${isAIAssistantDisabled ? 'disabled-local' : ''}\" data-tab=\"ai_assistant\" ${isAIAssistantDisabled ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''}>AI Assistant</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n if (tab.classList.contains('disabled-local')) return;\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'folder_sync') {\n renderFolderSyncTab(tc);\n return;\n }\n\n if (activeTab === 'ai_assistant') {\n renderAIAssistantTab(tc);\n return;\n }\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'cost_centers') {\n renderCostCentersTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n\n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Standard Logo (Large) using Canvas to ~600x300 max bounding box\n compressImage(re.target.result, 600, 300).then((compressed) => {\n pendingLogo = compressed;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Small/Shrunk Logo using Canvas to ~200x100 max bounding box\n compressImage(re.target.result, 200, 100).then((compressed) => {\n pendingLogoSmall = compressed;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n\n\n tc.querySelector('#btn-save-company').addEventListener('click', async () => {\n const saveBtn = tc.querySelector('#btn-save-company');\n const originalHtml = saveBtn.innerHTML;\n saveBtn.disabled = true;\n saveBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving Changes...';\n\n try {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n await store.saveSettings(settings);\n showToast('Company information saved successfully to database', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n render();\n } catch (err) {\n console.error('Error saving company profile settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n saveBtn.disabled = false;\n saveBtn.innerHTML = originalHtml;\n }\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n const pendingResets = store.getAll('passwordResetRequests') || [];\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div style=\"background:rgba(59, 130, 246, 0.1); border-left:4px solid #3b82f6; padding:12px 16px; margin-bottom:var(--space-md); border-radius:4px; font-size:13px; color:#f8fafc; display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:#3b82f6; font-size:18px;\">info</span>\n <span>Your company login code is <strong style=\"color:#60a5fa\">${companySlug}</strong>. Technicians log in using <strong style=\"color:#60a5fa\">username@${companySlug}</strong>.</span>\n </span>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Username</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n ${ut?.id !== 'ut_admin' ? `\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n ` : ''}\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\">\n <h4 style=\"margin:0\">Pending Password Reset Requests</h4>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Username/Email</th>\n <th>Requested On</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:24px\">No pending reset requests</td></tr>' : ''}\n ${pendingResets.filter(r => r.status === 'pending').map(r => {\n const tech = techs.find(t => t.id === r.technician_id) || {};\n const requestedAt = r.requested_at ? new Date(r.requested_at).toLocaleString() : 'Unknown';\n return `\n <tr>\n <td class=\"font-medium\">${tech.name || 'Unknown'}</td>\n <td class=\"text-secondary\">${r.employee_id || tech.username || ''}</td>\n <td class=\"text-tertiary\">${requestedAt}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-approve-reset\" data-id=\"${r.id}\" data-tech-id=\"${tech.id}\">Approve &amp; Reset</button>\n <button class=\"btn btn-sm btn-ghost text-danger btn-deny-reset\" data-id=\"${r.id}\">Deny</button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n ${isLocalMode ? '' : `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `}\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => {\n openUserModal();\n });\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-approve-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const techId = e.currentTarget.dataset.techId;\n const t = store.getById('technicians', techId);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <p>Approve password reset request for <strong>${t.name}</strong>? Please enter their new password below:</p>\n <div class=\"form-group\" style=\"margin-top:12px\">\n <label class=\"form-label\">New Password</label>\n <input type=\"password\" id=\"admin-reset-pwd-input\" class=\"form-input\" placeholder=\"Min. 6 characters\" minlength=\"6\" autofocus />\n </div>\n `;\n showModal({\n title: 'Approve Password Reset',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reset Password', className: 'btn-primary', onClick: c => {\n const pwdInput = document.getElementById('admin-reset-pwd-input');\n const newPwd = pwdInput ? pwdInput.value : '';\n if (!newPwd || newPwd.length < 6) {\n showToast('Password must be at least 6 characters.', 'error');\n return;\n }\n \n // Update technician password\n store.update('technicians', techId, { password: newPwd });\n \n // Approve the request\n store.update('passwordResetRequests', id, { status: 'approved', updated_at: new Date().toISOString() });\n \n showToast(`Password updated for ${t.name}`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-deny-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n store.update('passwordResetRequests', id, { status: 'denied', updated_at: new Date().toISOString() });\n showToast('Password reset request denied', 'info');\n renderContent();\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n let currentJobTypes = settings.jobTypes ? [...settings.jobTypes] : ['Electrical', 'Plumbing', 'HVAC', 'Fire Protection', 'Security', 'General Maintenance', 'Service', 'Project', 'Maintenance', 'Quote'];\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg)\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax Rates</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\" style=\"display:flex; flex-direction:column; gap:8px\">\n <label class=\"form-label\" style=\"display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:0\">\n <input type=\"checkbox\" id=\"tax-enabled\" style=\"width:16px; height:16px; margin:0\" ${settings.taxEnabled !== false ? 'checked' : ''} />\n Enable GST / Sales Tax\n </label>\n <div style=\"display:${settings.taxEnabled !== false ? 'flex' : 'none'}; align-items:center; gap:8px; margin-top:4px\" id=\"tax-rate-container\">\n <input class=\"form-input\" id=\"tax-rate\" type=\"number\" value=\"${settings.taxRate !== undefined ? settings.taxRate : 10}\" style=\"width:100px\" min=\"0\" max=\"100\" step=\"0.1\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" id=\"job-types-card\">\n <div class=\"card-header\"><h4>Job Types</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:12px\">Define the types of jobs your team performs (e.g. Electrical, Plumbing). These appear in job forms and rate mapping.</p>\n <div id=\"job-types-list\" style=\"display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px;\">\n ${currentJobTypes.map(type => `\n <span class=\"tag-pill tag-pill-active\" style=\"display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:100px; font-weight:500; font-size:13px; background:var(--color-primary-light); color:var(--color-primary-dark)\">\n <span>${escapeHTML(type)}</span>\n <span class=\"material-icons-outlined delete-job-type-btn\" data-type=\"${escapeHTML(type)}\" style=\"font-size:16px; cursor:pointer; opacity:0.7; transition:opacity 0.2s\">close</span>\n </span>\n `).join('')}\n </div>\n <div style=\"display:flex; gap:8px;\">\n <input class=\"form-input\" id=\"new-job-type-input\" type=\"text\" placeholder=\"e.g. Renovation\" style=\"flex:1\" />\n <button class=\"btn btn-secondary\" id=\"add-job-type-btn\" style=\"display:flex; align-items:center; gap:4px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${escapeHTML(rate.name)}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${escapeHTML(rate.description || '')}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => {\n const active = applicable.includes(day);\n return `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${active ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'};background:${active ? 'var(--color-primary-light)' : 'transparent'};color:${active ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `}).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // Toggle tax rate container visibility\n tc.querySelector('#tax-enabled')?.addEventListener('change', (e) => {\n const container = tc.querySelector('#tax-rate-container');\n if (container) {\n container.style.display = e.target.checked ? 'flex' : 'none';\n }\n });\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Job Types Management Event Listeners ----\n const addJobTypeBtn = tc.querySelector('#add-job-type-btn');\n const newJobTypeInput = tc.querySelector('#new-job-type-input');\n const jobTypesList = tc.querySelector('#job-types-list');\n\n function updateJobTypesUI() {\n jobTypesList.innerHTML = currentJobTypes.map(type => `\n <span class=\"tag-pill tag-pill-active\" style=\"display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border-radius:100px; font-weight:500; font-size:13px; background:var(--color-primary-light); color:var(--color-primary-dark)\">\n <span>${escapeHTML(type)}</span>\n <span class=\"material-icons-outlined delete-job-type-btn\" data-type=\"${escapeHTML(type)}\" style=\"font-size:16px; cursor:pointer; opacity:0.7; transition:opacity 0.2s\">close</span>\n </span>\n `).join('');\n }\n\n addJobTypeBtn?.addEventListener('click', () => {\n const val = newJobTypeInput.value.trim();\n if (!val) {\n showToast('Please enter a job type name', 'warning');\n return;\n }\n if (currentJobTypes.includes(val)) {\n showToast('This job type already exists', 'warning');\n return;\n }\n currentJobTypes.push(val);\n newJobTypeInput.value = '';\n updateJobTypesUI();\n });\n\n tc.querySelector('#job-types-card')?.addEventListener('click', (e) => {\n const btn = e.target.closest('.delete-job-type-btn');\n if (btn) {\n const typeToDelete = btn.dataset.type;\n currentJobTypes = currentJobTypes.filter(t => t !== typeToDelete);\n updateJobTypesUI();\n }\n });\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#save-tax-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const taxEnabled = tc.querySelector('#tax-enabled').checked;\n const taxRate = parseFloat(tc.querySelector('#tax-rate').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.taxEnabled = taxEnabled;\n settings.taxRate = taxRate;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n settings.jobTypes = currentJobTypes;\n\n await store.saveSettings(settings);\n showToast('Financial and Rate settings saved successfully', 'success');\n renderContent();\n } catch (err) {\n console.error('Error saving tax and rate settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-portal-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n await store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving customer portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'system') {\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n const currentTheme = (currentUser && currentUser.id) ? (currentUser.theme || localStorage.getItem(`simpro_theme_${currentUser.id}`) || 'light') : 'light';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Left Column -->\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg)\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n </p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n ${currentUser.role === 'admin' ? `\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed a complete demonstration dataset\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed Demonstration Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n <button class=\"btn btn-danger\" id=\"btn-delete-company\" data-tooltip=\"Permanently delete the entire company profile and all data\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; background:var(--color-danger); border-color:var(--color-danger); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span> Delete Company Profile\n </button>\n\n ${isLocalMode ? `\n <div style=\"margin-top:16px; padding-top:12px; border-top:1px dashed rgba(220, 38, 38, 0.2)\">\n <h6 style=\"color:var(--color-warning); margin-bottom:8px; font-weight:600; display:flex; align-items:center; gap:4px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">warning</span> Irreversible Profile Conversions\n </h6>\n <p style=\"font-size:11px; color:var(--text-secondary); margin-bottom:12px; line-height:1.3;\">\n Convert your deployment type. These transitions are permanent and one-way.\n </p>\n ${localDeploymentType === 'single_user' ? `\n <button class=\"btn\" id=\"btn-convert-multiuser\" style=\"width:100%; justify-content:center; margin-bottom:12px; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">lan</span> Convert to Multi-User Local Network Sync\n </button>\n ` : ''}\n <button class=\"btn\" id=\"btn-convert-cloud-action\" style=\"width:100%; justify-content:center; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">cloud_upload</span> Convert to Cloud Sync\n </button>\n </div>\n ` : ''}\n </div>\n ` : '<div style=\"font-size:12.5px; color:var(--text-tertiary)\">Administrative actions are restricted to company administrators.</div>'}\n </div>\n </div>\n </div>\n\n <!-- Local Data Backup -->\n ${!isLocalMode && currentUser.role === 'admin' ? `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary)\">backup</span>\n <h4 style=\"margin:0;\">Local Data Backup</h4>\n </div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:12px;\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm); line-height:1.4; margin:0;\">\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n </p>\n <div id=\"backup-status-container\"></div>\n </div>\n </div>\n ` : ''}\n </div>\n\n <!-- Right Column -->\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">System Color Theme</label>\n <select class=\"form-select\" id=\"system-theme-select\" style=\"width:100%\">\n ${Object.entries(THEMES).map(([key, val]) => `\n <option value=\"${key}\" ${currentTheme === key ? 'selected' : ''}>${val.name}</option>\n `).join('')}\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Select a color profile and visual background effect for your user interface.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', async (e) => {\n const select = e.target;\n select.disabled = true;\n try {\n const settings = store.getSettings();\n settings.tooltipPreference = select.value;\n await store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved successfully', 'success');\n } catch (err) {\n console.error('Error saving tooltip preference:', err);\n showToast('Failed to save preferences: ' + (err.message || err), 'error');\n } finally {\n select.disabled = false;\n }\n });\n\n tc.querySelector('#system-theme-select')?.addEventListener('change', (e) => {\n applyTheme(e.target.value, true);\n showToast('System theme updated successfully', 'success');\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a complete demonstration dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load a complete, realistic demonstration dataset representing an active trade business (5 customers, 5 quotes/jobs per customer, 5 assets per customer, 5 materials, 5 users/timesheets, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for testing and walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed Demonstration Data\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Demo Data\",\n className: \"btn-primary\",\n onClick: async (close) => {\n close();\n showToast('Seeding demonstration data...', 'info');\n await seedMinimalData();\n showToast('Demonstration data seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-delete-company')?.addEventListener('click', () => {\n // Modal Warning 1 of 2\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">warning</span>\n <span>You are about to permanently delete the entire company profile for <strong>${escapeHTML(store.getSettings().name || '')}</strong> and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).</span>\n </div>\n <p style=\"font-size:12.5px; color:var(--text-secondary); margin-bottom:12px\">\n This action is final, irreversible, and cannot be undone under any circumstances.\n </p>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Do you want to proceed to password confirmation?</p>\n `;\n\n showModal({\n title: \"Delete Company Profile - Step 1 of 2\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (closeModal) => closeModal()\n },\n {\n label: \"Proceed to Confirm\",\n className: \"btn-danger\",\n onClick: (closeModal) => {\n closeModal();\n \n // Modal Warning 2 of 2\n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <form id=\"delete-company-confirm-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <p style=\"margin-bottom:8px; font-weight:600; color:var(--color-danger)\">FINAL CONFIRMATION (2 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">gavel</span>\n <span>To authorize the permanent destruction of this company, you must enter the Administrator password.</span>\n </div>\n\n <div class=\"form-group\" style=\"margin-top:12px;\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Password</label>\n <input class=\"form-input\" type=\"password\" id=\"delete-confirm-password\" required placeholder=\"Enter admin password to proceed\" />\n </div>\n\n <div id=\"delete-confirm-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"delete-confirm-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-delete-abort\">Abort Deletion</button>\n <button type=\"submit\" class=\"btn btn-danger\" id=\"btn-delete-confirm-submit\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span>\n <span>Permanently Delete Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close: close2 } = showModal({\n title: \"⚠️ Confirm Company Deletion - Step 2 of 2\",\n content: content2,\n size: \"modal-md\"\n });\n\n content2.querySelector('#btn-delete-abort').addEventListener('click', close2);\n\n const form2 = content2.querySelector('#delete-company-confirm-form');\n form2.addEventListener('submit', async (ev) => {\n ev.preventDefault();\n\n const errorEl = content2.querySelector('#delete-confirm-error');\n const errorTextEl = content2.querySelector('#delete-confirm-error-text');\n const submitBtn = content2.querySelector('#btn-delete-confirm-submit');\n const abortBtn = content2.querySelector('#btn-delete-abort');\n const passwordInput = content2.querySelector('#delete-confirm-password').value;\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n abortBtn.disabled = true;\n const origSubmitText = submitBtn.innerHTML;\n submitBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Deleting...';\n\n try {\n let passwordVerified = false;\n\n if (isLocalMode) {\n if (localDeploymentType === 'single_user') {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acct = storedAccounts.find(a => a.id === activeAccountId);\n if (acct && acct.hasPassword) {\n const hashedInput = await hashPassword(passwordInput);\n if (hashedInput === acct.passwordHash) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n // No password set\n passwordVerified = true;\n }\n } else {\n // local_multiuser\n const techs = store.getAll('technicians') || [];\n const currentTech = techs.find(t => t.id === currentUser.id);\n const expectedPassword = currentTech ? currentTech.password : '123456';\n if (passwordInput === expectedPassword) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n }\n } else {\n // Cloud mode\n const { supabase } = await import('../utils/supabase.js');\n const { data: { user } } = await supabase.auth.getUser();\n if (user && user.email) {\n const { error } = await supabase.auth.signInWithPassword({\n email: user.email,\n password: passwordInput\n });\n if (!error) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n throw new Error('Could not retrieve logged-in cloud user credentials.');\n }\n }\n\n if (passwordVerified) {\n // 1. If cloud mode, delete the company row in Supabase\n if (!isLocalMode) {\n const { supabase } = await import('../utils/supabase.js');\n const { error: deleteErr } = await supabase.from('companies').delete().eq('id', store.companyId);\n if (deleteErr) {\n throw new Error('Failed to delete cloud company: ' + deleteErr.message);\n }\n }\n\n // 2. Trigger store.clearAll()\n store.clearAll();\n\n // 3. Clear local account launcher keys if local mode\n if (isLocalMode) {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const updatedAccounts = storedAccounts.filter(a => a.id !== activeAccountId);\n await storageSet('relay_accounts', updatedAccounts);\n }\n }\n\n // 4. Remove session user & active account\n localStorage.removeItem('currentUser');\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Company profile deleted successfully.', 'success');\n close2();\n\n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n } catch (err) {\n console.error('Company deletion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during verification.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n abortBtn.disabled = false;\n submitBtn.innerHTML = origSubmitText;\n }\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-convert-multiuser')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n\n const techs = store.getAll('technicians') || [];\n const companyId = store.companyId || '';\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const existingAdmin = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n const currentTechName = existingAdmin ? existingAdmin.name : (currentUser.name && currentUser.name !== 'Local Admin' ? currentUser.name : '');\n const currentTechUsernameOrEmail = existingAdmin ? (existingAdmin.email || existingAdmin.username || '') : '';\n\n content.innerHTML = `\n <form id=\"convert-multiuser-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Multi-User Local Network Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a single-user Local Admin profile.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"multiuser-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"e.g. Apex Power Services\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"multiuser-admin-name\" required value=\"${escapeHTML(currentTechName)}\" placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Email Address (Required)</label>\n <input class=\"form-input\" type=\"email\" id=\"multiuser-admin-email\" required value=\"${escapeHTML(currentTechUsernameOrEmail)}\" placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Admin Password</label>\n <input class=\"form-input\" type=\"password\" id=\"multiuser-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"multiuser-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"multiuser-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-multiuser-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-multiuser-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">lan</span>\n <span>Convert Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: \"Convert to Multi-User Local Sync\",\n content: content,\n size: \"modal-md\"\n });\n\n content.querySelector('#btn-multiuser-cancel').addEventListener('click', close);\n\n const form = content.querySelector('#convert-multiuser-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n const errorEl = content.querySelector('#multiuser-error');\n const errorTextEl = content.querySelector('#multiuser-error-text');\n const submitBtn = content.querySelector('#btn-multiuser-submit');\n const cancelBtn = content.querySelector('#btn-multiuser-cancel');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n\n const businessName = content.querySelector('#multiuser-company-name').value.trim();\n const adminName = content.querySelector('#multiuser-admin-name').value.trim();\n const adminEmail = content.querySelector('#multiuser-admin-email').value.trim();\n const adminPassword = content.querySelector('#multiuser-admin-password').value;\n\n try {\n if (!businessName || !adminName || !adminEmail || !adminPassword) {\n throw new Error('All fields are required.');\n }\n if (adminPassword.length < 6) {\n throw new Error('Password must be at least 6 characters.');\n }\n\n // 1. Update settings\n const settings = store.getSettings();\n settings.name = businessName;\n settings.localDeploymentType = 'multi_user';\n await store.saveSettings(settings);\n\n // 2. Update launcher accounts businessName\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acctIndex = storedAccounts.findIndex(a => a.id === activeAccountId);\n if (acctIndex !== -1) {\n storedAccounts[acctIndex].businessName = businessName;\n await storageSet('relay_accounts', storedAccounts);\n }\n }\n\n // 3. Create or update admin user in technicians\n const companyId = store.companyId;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const techs = store.getAll('technicians') || [];\n let adminTech = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n if (!adminTech) {\n adminTech = {\n id: `${companyId}_tech_admin_created`,\n role: 'Admin',\n color: '#FF5C00',\n userTypeId: adminTypeId,\n payRate: 100.00,\n phone: ''\n };\n techs.push(adminTech);\n }\n\n adminTech.name = adminName;\n adminTech.username = adminEmail.split('@')[0];\n adminTech.email = adminEmail;\n adminTech.password = adminPassword;\n\n store.save('technicians', techs);\n\n // 4. Update currentUser session in localStorage\n const localUser = {\n id: adminTech.id,\n companyId: companyId,\n name: adminTech.name,\n role: 'admin',\n userTypeName: 'Admin',\n userTypeId: adminTech.userTypeId,\n color: adminTech.color || '#FF5C00',\n theme: 'light'\n };\n localStorage.setItem('currentUser', JSON.stringify(localUser));\n\n showToast('Converted to Multi-User Local Network Sync. Reloading...', 'success');\n close();\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n setTimeout(() => window.location.reload(), 1200);\n } catch (err) {\n console.error('Conversion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during conversion.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n }\n });\n });\n\n tc.querySelector('#btn-convert-cloud-action')?.addEventListener('click', () => {\n openMigrationModal();\n });\n\n const renderBackupStatus = () => {\n const bsc = tc.querySelector('#backup-status-container');\n if (!bsc) return;\n\n const hasHandle = !!store.backupDirHandle;\n const isPermissionGranted = store.backupDirPermissionGranted;\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const formattedLastBackup = lastBackup ? new Date(lastBackup).toLocaleString() : 'Never';\n const isSupported = typeof window !== 'undefined' && window.showDirectoryPicker;\n\n if (!isSupported) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-danger); line-height:1.4;\">\n <strong style=\"display:block; margin-bottom:4px;\">Browser Local Folder Access Unsupported</strong>\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n </div>\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:12px; border-radius:6px; margin-bottom:12px; color:var(--text-secondary); display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary);\">folder_off</span>\n <div style=\"font-size:12.5px;\">No backup folder configured.</div>\n </div>\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">folder</span> Choose Backup Folder...\n </button>\n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-warning); line-height:1.4; margin-bottom:12px;\">\n <strong>Permission Required</strong><br/>\n Access permission to <strong>${escapeHTML(store.backupDirHandle.name)}</strong> has expired. Please re-authorize.\n </div>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-warning\" id=\"btn-backup-auth\" style=\"flex:1; justify-content:center;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize & Backup\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n `;\n } else {\n bsc.innerHTML = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-success); line-height:1.4; margin-bottom:12px;\">\n <strong>Backup Configured</strong><br/>\n Folder: <strong>${escapeHTML(store.backupDirHandle.name)}</strong><br/>\n Last Backup: <strong>${formattedLastBackup}</strong>\n </div>\n <div style=\"display:flex; flex-direction:column; gap:8px;\">\n <button class=\"btn btn-primary\" id=\"btn-backup-now\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">backup</span> Backup Now\n </button>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"flex:1; justify-content:center; border:1px solid var(--border-color);\">\n <span class=\"material-icons-outlined\">folder</span> Change Folder...\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n </div>\n `;\n }\n\n // Attach event listeners for backup buttons inside container\n bsc.querySelector('#btn-backup-pick')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setBackupDirectory(handle);\n showToast('Backup directory selected successfully.', 'success');\n showToast('Running initial backup...', 'info');\n await store.backupToFolder(handle);\n showToast('Backup completed successfully!', 'success');\n renderBackupStatus();\n } catch (err) {\n console.error('Failed to select backup directory:', err);\n if (err.name !== 'AbortError') {\n showToast('Failed to configure backup: ' + err.message, 'error');\n }\n }\n });\n\n bsc.querySelector('#btn-backup-auth')?.addEventListener('click', async () => {\n const granted = await store.verifyBackupDirPermission(true);\n if (granted) {\n showToast('Permission re-authorized. Backing up...', 'info');\n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n showToast('Backup failed: ' + err.message, 'error');\n }\n } else {\n showToast('Failed to acquire write permission.', 'error');\n }\n renderBackupStatus();\n });\n\n bsc.querySelector('#btn-backup-now')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Backing up...';\n \n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Backup failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n renderBackupStatus();\n }\n });\n\n bsc.querySelector('#btn-backup-disconnect')?.addEventListener('click', async () => {\n await store.setBackupDirectory(null);\n showToast('Backup folder disconnected.', 'info');\n renderBackupStatus();\n });\n };\n\n if (!isLocalMode && currentUser.role === 'admin') {\n renderBackupStatus();\n }\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', username: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Username</label>\n <input class=\"form-input\" id=\"u-username\" value=\"${t.username || (t.email ? t.email.split('@')[0] : '')}\" ${editId ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''} placeholder=\"e.g. joshua\" />\n ${!editId ? `\n <div style=\"font-size: 11px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500;\">\n Company login code is: <strong style=\"color: var(--color-primary)\">${companySlug}</strong>. User will log in with <strong style=\"color: var(--color-primary)\">username@${companySlug}</strong>\n </div>\n ` : ''}\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">${editId ? 'Reset Password (leave blank to keep current)' : 'Temporary Password (assigned for worker login)'}</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"${editId ? '••••••••' : 'Min. 6 characters'}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\" ${(t.userTypeId === 'ut_admin' || (t.userTypeId && t.userTypeId.endsWith('_ut_admin'))) ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''}>\n <option value=\"\">-- Select --</option>\n ${userTypes\n .filter(ut => (!ut.id.endsWith('_ut_admin') && ut.id !== 'ut_admin') || t.userTypeId === ut.id)\n .map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value.trim();\n const username = document.getElementById('u-username').value.trim();\n const role = document.getElementById('u-role').value.trim();\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = document.getElementById('u-password')?.value || '';\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!username) { showToast('Username required', 'error'); return; }\n if (username.includes('@')) { showToast('Username cannot contain @ symbol', 'error'); return; }\n \n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (password && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n const updates = { name, username, role, userTypeId, color, payRate };\n if (password) {\n updates.password = password;\n }\n\n if (editId) {\n await store.update('technicians', editId, updates);\n } else {\n await store.create('technicians', updates);\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = async () => {\n const btn = tc.querySelector('#btn-save-materials');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n await store.saveSettings(updatedSettings);\n showToast('Material settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving material settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-00023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-00129',\n originalQuoteNumber: 'Q-00412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' },\n ballet: { name: 'Ballet Pointe', preset: 'ballet', accentColor: '#e58799', headerBg: '#5c2c36', accentTint: '#fff5f6', fontFamily: 'serif' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-company-name\" style=\"width:16px; height:16px\" ${dt.hideCompanyName ? 'checked' : ''} />\n <label for=\"theme-hide-company-name\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide company name text (use logo only)</label>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Variation</label>\n <select class=\"form-select\" id=\"theme-logo-source\" style=\"width:100%\">\n <option value=\"large\" ${dt.logoSource === 'large' ? 'selected' : ''}>Standard Logo (Large)</option>\n <option value=\"small\" ${dt.logoSource === 'small' ? 'selected' : ''}>Small / Shrunk Logo (Small)</option>\n </select>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:8px 0\"></div>\n <div class=\"form-group ${isLocalMode ? 'disabled-local' : ''}\" ${isLocalMode ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''} style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-brand-logo\" style=\"width:16px; height:16px\" ${dt.hideBrandLogo ? 'checked' : ''} ${isLocalMode ? 'disabled' : ''} />\n <label for=\"theme-hide-brand-logo\" class=\"form-label\" style=\"margin:0; cursor:${isLocalMode ? 'not-allowed' : 'pointer'}\">Remove Relay-dispatch brand logo from document footers</label>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n <div class=\"form-row\" style=\"display:flex; gap:12px\">\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Invoice Prefix</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoicePrefix\" value=\"${escapeHTML(dt.invoicePrefix || 'INV-')}\" placeholder=\"INV-\" />\n </div>\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Invoice Starting Number</label>\n <input type=\"number\" class=\"form-input text-copy-input\" data-prop=\"invoiceStartingNumber\" value=\"${dt.invoiceStartingNumber !== undefined ? dt.invoiceStartingNumber : 1}\" min=\"1\" placeholder=\"1\" />\n </div>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n <div class=\"form-row\" style=\"display:flex; gap:12px\">\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Quote Prefix</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quotePrefix\" value=\"${escapeHTML(dt.quotePrefix || 'Q-')}\" placeholder=\"Q-\" />\n </div>\n <div class=\"form-group\" style=\"flex:1\">\n <label class=\"form-label\">Quote Starting Number</label>\n <input type=\"number\" class=\"form-input text-copy-input\" data-prop=\"quoteStartingNumber\" value=\"${dt.quoteStartingNumber !== undefined ? dt.quoteStartingNumber : 1}\" min=\"1\" placeholder=\"1\" />\n </div>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Hide Company Name toggle\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) {\n hideCompanyNameChk.addEventListener('change', () => {\n dt.hideCompanyName = hideCompanyNameChk.checked;\n updatePreview();\n });\n }\n\n // Logo Variation toggle\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) {\n logoSourceSelect.addEventListener('change', (e) => {\n dt.logoSource = e.target.value;\n updatePreview();\n });\n }\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Hide Brand Logo toggle\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) {\n hideBrandLogoChk.addEventListener('change', () => {\n dt.hideBrandLogo = hideBrandLogoChk.checked;\n updatePreview();\n });\n }\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-theme-save');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n await store.saveSettings(freshSettings);\n showToast('Document customization settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving theme settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n hideCompanyName: false,\n hideBrandLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!',\n invoicePrefix: 'INV-',\n invoiceStartingNumber: 1,\n quotePrefix: 'Q-',\n quoteStartingNumber: 1\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) hideCompanyNameChk.checked = !!dt.hideCompanyName;\n\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) logoSourceSelect.value = dt.logoSource || 'large';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePrefixInp = tc.querySelector('[data-prop=\"invoicePrefix\"]');\n if (invoicePrefixInp) invoicePrefixInp.value = dt.invoicePrefix || '';\n\n const invoiceStartingNumberInp = tc.querySelector('[data-prop=\"invoiceStartingNumber\"]');\n if (invoiceStartingNumberInp) invoiceStartingNumberInp.value = dt.invoiceStartingNumber !== undefined ? dt.invoiceStartingNumber : 1;\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const quotePrefixInp = tc.querySelector('[data-prop=\"quotePrefix\"]');\n if (quotePrefixInp) quotePrefixInp.value = dt.quotePrefix || '';\n\n const quoteStartingNumberInp = tc.querySelector('[data-prop=\"quoteStartingNumber\"]');\n if (quoteStartingNumberInp) quoteStartingNumberInp.value = dt.quoteStartingNumber !== undefined ? dt.quoteStartingNumber : 1;\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) hideBrandLogoChk.checked = !!dt.hideBrandLogo;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n const logoUrl = dt.logoSource === 'small' ? (settings.logoSmall || settings.logo) : (settings.logo || settings.logoSmall);\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.src = logoUrl || '';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // Company Name visibility\n const companyNameEl = frameDoc.querySelector('.pdf-company-name');\n if (companyNameEl) {\n companyNameEl.style.display = dt.hideCompanyName ? 'none' : 'block';\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n\n const footerBrandingEl = frameDoc.querySelector('.pdf-footer-branding');\n if (footerBrandingEl) {\n const hideBrandLogo = isLocalMode ? false : !!dt.hideBrandLogo;\n footerBrandingEl.style.display = hideBrandLogo ? 'none' : 'flex';\n }\n }\n\n renderMarkup();\n }\n\n function renderFolderSyncTab(tc) {\n const isSupported = typeof window !== 'undefined' && (\n (window.indexedDB && window.showDirectoryPicker) ||\n (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem)\n );\n\n const isCapacitor = typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem;\n\n const activeAccountId = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n const isLocalCompany = !!activeAccountId;\n\n function render() {\n console.log('[DEBUG Settings] render: isLocalCompany =', isLocalCompany, 'store.dirHandle =', store.dirHandle, 'permissionGranted =', store.folderSyncPermissionGranted);\n const isEnabled = isLocalCompany ? true : store.folderSyncEnabled;\n const isPermissionGranted = store.folderSyncPermissionGranted;\n const hasHandle = !!store.dirHandle;\n\n let statusHtml = '';\n if (!isSupported) {\n statusHtml = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:16px; border-radius:4px; color:var(--color-danger); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:8px; font-weight:600; margin-bottom:4px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Browser Directory Access Unsupported</span>\n </div>\n <p style=\"font-size:var(--font-size-sm); margin:0; line-height:1.4;\">\n Your current browser does not support local folder access. To enable direct folder synchronization, please run this app in a Chromium-based browser (Chrome, Edge, Opera, or as a compiled native app). High-capacity IndexedDB storage remains active.\n </p>\n </div>\n `;\n } else if (!isEnabled) {\n statusHtml = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:16px; border-radius:6px; color:var(--text-secondary); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px; color:var(--text-tertiary);\">folder_off</span>\n <div>\n <div style=\"font-weight:600; color:var(--text-primary); margin-bottom:2px;\">Folder Synchronization Inactive</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4;\">All data is currently stored in your browser's private IndexedDB database sandbox.</p>\n </div>\n </div>\n `;\n } else if (isCapacitor) {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">cloud_done</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Capacitor Direct Folder Sync Active</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Data is synchronizing directly to the application's native <strong>Documents/RelayDispatchData</strong> directory on your iPad/device.\n </p>\n </div>\n </div>\n `;\n } else if (!hasHandle) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">warning</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">No Directory Selected</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">Please select a directory folder on your computer to begin syncing.</p>\n </div>\n </div>\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:12px; margin-bottom:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">lock</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Access Permission Suspended</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n The browser requires re-authorization to read/write files in <strong>${escapeHTML(store.dirHandle.name)}</strong>.\n </p>\n </div>\n </div>\n <button class=\"btn btn-warning\" id=\"btn-reauthorize-dir\" style=\"margin-left:44px;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize Access\n </button>\n </div>\n `;\n } else {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">check_circle</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Folder Sync Active & Synchronized</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Active Folder: <strong>${escapeHTML(store.dirHandle.name)}</strong>. All data edits are writing dynamically.\n </p>\n </div>\n </div>\n `;\n }\n\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <!-- Folder Configuration -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Direct Directory Sync</h4></div>\n <div class=\"card-body\">\n ${statusHtml}\n\n <div class=\"form-group\" style=\"margin-bottom:var(--space-lg);\">\n <label class=\"form-label\" style=\"font-weight:600; margin-bottom:8px;\">Synchronization Toggle</label>\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\">\n <input type=\"checkbox\" id=\"toggle-folder-sync\" ${isEnabled ? 'checked' : ''} ${(!isSupported || isLocalCompany) ? 'disabled' : ''} style=\"width:20px; height:20px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\" />\n <div>\n <span style=\"font-weight:500;\">Enable Direct Local Folder Storage</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">\n ${isLocalCompany ? 'Mandatory for offline/local company profile storage.' : 'Saves all data records and attachments straight to your machine.'}\n </div>\n </div>\n </label>\n </div>\n\n ${isEnabled && isSupported && !isCapacitor ? `\n <div style=\"display:flex; gap:12px; flex-wrap:wrap; border-top:1px solid var(--border-color); padding-top:var(--space-lg);\">\n <button class=\"btn btn-secondary\" id=\"btn-pick-dir\">\n <span class=\"material-icons-outlined\">folder</span> Choose Sync Folder...\n </button>\n ${hasHandle && isPermissionGranted ? `\n <button class=\"btn btn-secondary\" id=\"btn-force-sync\" data-tooltip=\"Overwrite folder JSONs with current memory cache\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">sync</span> Sync Now\n </button>\n ${!isLocalCompany ? `\n <button class=\"btn btn-danger\" id=\"btn-disconnect-dir\">\n <span class=\"material-icons-outlined\">link_off</span> Disconnect Folder\n </button>\n ` : ''}\n ` : ''}\n </div>\n ` : ''}\n </div>\n </div>\n\n <!-- Instructions Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>How Self-Hosting Works</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p>\n By linking a local folder, you establish a <strong>serverless self-hosted data hub</strong>.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">storage</span>\n <span><strong>Readable Data:</strong> Your jobs, quotes, and company configurations are saved in <code>data/*.json</code> files.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">photo_library</span>\n <span><strong>Physical Attachments:</strong> Uploaded photos and PDF catalogs are saved as standard image/PDF files under the <code>documents/</code> subfolder.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">sync_alt</span>\n <span><strong>Cloud Backups:</strong> Select a folder synced to <strong>OneDrive, Google Drive, or Dropbox</strong> to automatically back up and sync your data to the cloud.</span>\n </div>\n </div>\n </div>\n </div>\n `;\n\n // Event Listeners\n const toggle = tc.querySelector('#toggle-folder-sync');\n toggle?.addEventListener('change', async (e) => {\n if (isLocalCompany) {\n e.preventDefault();\n toggle.checked = true;\n return;\n }\n\n if (isCapacitor) {\n await store.setLocalDirectory(e.target.checked ? {} : null);\n showToast(e.target.checked ? 'Capacitor folder sync activated' : 'Folder sync deactivated', 'success');\n render();\n return;\n }\n\n if (e.target.checked) {\n // Trigger directory picker\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory configured successfully', 'success');\n } catch (err) {\n console.error('Directory selection cancelled or failed:', err);\n toggle.checked = false;\n showToast('Folder selection cancelled', 'info');\n }\n } else {\n await store.setLocalDirectory(null);\n showToast('Direct directory synchronization disabled', 'info');\n }\n render();\n });\n\n tc.querySelector('#btn-pick-dir')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory updated successfully', 'success');\n render();\n } catch (err) {\n console.error(err);\n }\n });\n\n tc.querySelector('#btn-reauthorize-dir')?.addEventListener('click', async () => {\n const granted = await store.verifyDirPermission(true);\n if (granted) {\n showToast('Folder access re-authorized successfully', 'success');\n } else {\n showToast('Failed to acquire write permissions', 'error');\n }\n render();\n });\n\n tc.querySelector('#btn-disconnect-dir')?.addEventListener('click', async () => {\n await store.setLocalDirectory(null);\n showToast('Local folder disconnected', 'info');\n render();\n });\n\n tc.querySelector('#btn-force-sync')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n btn.disabled = true;\n showToast('Writing cache data to local directory...', 'info');\n try {\n const collections = Object.keys(store.cache);\n await Promise.all(collections.map(col => store.writeCollectionToFolder(col, store.cache[col])));\n showToast('Folder sync completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Folder sync failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n }\n });\n }\n\n render();\n }\n\n function renderAIAssistantTab(tc) {\n const s = store.getSettings();\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const ai = s.ai || {\n enabled: false,\n apiKey: '',\n endpoint: 'https://api.openai.com/v1/chat/completions',\n model: 'gpt-4o-mini',\n systemPrompt: 'You are Relay, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n };\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null') || { id: 'default' };\n const userId = currentUser.id || 'default';\n const factsheetKey = `relay_factsheet_${userId}`;\n const enabledKey = `relay_factsheet_enabled_${userId}`;\n const factsheetVal = localStorage.getItem(factsheetKey) || '';\n const memoryEnabled = localStorage.getItem(enabledKey) !== 'false';\n\n function render() {\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg);\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>AI Assistant Configuration</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n \n <div class=\"form-group\">\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:pointer;\">\n <input type=\"checkbox\" id=\"ai-enabled\" ${ai.enabled ? 'checked' : ''} style=\"width:20px; height:20px; cursor:pointer;\" />\n <div>\n <span style=\"font-weight:600;\">Enable AI Assistant</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">Replaces the basic rule-based Relay co-pilot with a smart conversational LLM.</div>\n </div>\n </label>\n </div>\n\n <div id=\"ai-fields\" style=\"display: ${ai.enabled ? 'flex' : 'none'}; flex-direction:column; gap:16px; border-top:1px solid var(--border-color); padding-top:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">API Key</label>\n <input type=\"password\" class=\"form-input\" id=\"ai-apikey\" value=\"${ai.apiKey || ''}\" placeholder=\"${isLocalMode ? 'sk-...' : 'Optional — Defaults to secure cloud API key'}\" />\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">${isLocalMode ? 'Your API key is stored locally in your browser/sync directory and shared amongst employees.' : 'Your API key is optional. If left blank, cloud operations will securely route through Supabase Edge Functions.'}</div>\n </div>\n\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">API Endpoint</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-endpoint\" value=\"${ai.endpoint || 'https://api.openai.com/v1/chat/completions'}\" placeholder=\"https://api.openai.com/v1/chat/completions\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Model Name</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-model\" value=\"${ai.model || 'gpt-4o-mini'}\" placeholder=\"gpt-4o-mini\" />\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Base System Prompt</label>\n <textarea class=\"form-input\" id=\"ai-systemprompt\" rows=\"4\" style=\"font-family:inherit; resize:vertical;\">${ai.systemPrompt || ''}</textarea>\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.</div>\n </div>\n\n <div style=\"display:flex; gap:12px; align-items:center; margin-top:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-test-ai\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">bolt</span> Test Connection\n </button>\n <span id=\"test-status\" style=\"font-size:13px; font-weight:500;\"></span>\n </div>\n <div id=\"test-error-details\" style=\"display:none; margin-top:8px; font-size:12px; color:var(--color-danger); background:var(--color-danger-bg); border-left:3px solid var(--color-danger); padding:8px; border-radius:4px; line-height:1.4;\"></div>\n </div>\n\n <div style=\"border-top:1px solid var(--border-color); padding-top:16px; display:flex; justify-content:flex-end;\">\n <button class=\"btn btn-primary\" id=\"btn-save-ai\">Save AI Config</button>\n </div>\n\n </div>\n </div>\n\n <!-- AI Co-Pilot Memory & Factsheet -->\n <div class=\"card\" id=\"ai-memory-card\" style=\"display: ${ai.enabled ? 'block' : 'none'};\">\n <div class=\"card-header\"><h4>Your Personal Co-Pilot Factsheet (User Memory)</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:12px;\">\n <div class=\"text-tertiary\" style=\"font-size:12.5px; line-height:1.4; color: var(--text-secondary);\">\n This factsheet stores preferences, custom habits, and patterns specific to your user profile (e.g. how you write notes, your default technicians, or the types of jobs you handle). Relay loads this context dynamically. You can edit it manually below, or tell Relay to remember facts in the chat.\n </div>\n \n <div class=\"form-group\" style=\"margin-top:4px;\">\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px;\">\n <input type=\"checkbox\" id=\"settings-ai-memory-enabled\" ${memoryEnabled ? 'checked' : ''} style=\"width:16px; height:16px; cursor:pointer;\" />\n <strong>Make my day easier</strong>\n </label>\n <div class=\"text-tertiary\" style=\"font-size:11.5px; margin-top:2px; margin-left:24px; line-height:1.3; color: var(--text-secondary);\">\n Allows Relay to automatically learn your preferences, default assignees, and work patterns during chats to customize the co-pilot around your day.\n </div>\n </div>\n\n <div class=\"form-group\" id=\"settings-factsheet-group\" style=\"margin-top: 8px; display: ${memoryEnabled ? 'block' : 'none'};\">\n <label class=\"form-label\" style=\"font-weight: 600;\">Personal Factsheet Context</label>\n <textarea class=\"form-input\" id=\"settings-ai-factsheet\" rows=\"5\" style=\"font-family:inherit; resize:vertical; font-size:12.5px;\" placeholder=\"Write down your preferences here (e.g. 'I prefer scheduling HVAC jobs to John Doe on Mondays' or 'I like writing job notes in bullet points').\">${escapeHTML(factsheetVal)}</textarea>\n </div>\n \n <div style=\"border-top:1px solid var(--border-color); padding-top:12px; display:flex; justify-content:flex-end; gap:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-save-factsheet\">Save Memory Settings</button>\n </div>\n </div>\n </div>\n\n </div>\n\n <!-- Cheapest Smart AI Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>Cheapest Smart AI Guide</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p style=\"margin:0;\">\n <strong>DeepSeek</strong> is one of the most cost-efficient and high-performance language models currently available, making it perfect for lightweight co-pilot tasks.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">key</span>\n <span><strong>API Key:</strong> Get your key from <a href=\"https://platform.deepseek.com/\" target=\"_blank\" style=\"color:var(--color-primary); text-decoration:underline;\">platform.deepseek.com</a>. New accounts typically receive free trial credits.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">security</span>\n <span><strong>CORS Restrictions:</strong> Due to browser security, direct API calls to DeepSeek may be blocked. To bypass this:\n <ul style=\"margin:4px 0 0 16px; padding:0; list-style:disc; font-size:12px;\">\n <li>Route via a CORS proxy (e.g. <code>https://cors-anywhere.herokuapp.com/</code> prefix).</li>\n <li>Connect via an aggregator like <strong>OpenRouter</strong> (API Endpoint: <code>https://openrouter.ai/api/v1/chat/completions</code>).</li>\n <li>Use a local LLM runner like <strong>Ollama</strong> (API Endpoint: <code>http://localhost:11434/v1/chat/completions</code>).</li>\n </ul>\n </span>\n </div>\n </div>\n </div>\n\n </div>\n `;\n\n // Toggle display of fields based on enabled checkbox\n const enabledCheckbox = tc.querySelector('#ai-enabled');\n const fieldsContainer = tc.querySelector('#ai-fields');\n enabledCheckbox.addEventListener('change', (e) => {\n fieldsContainer.style.display = e.target.checked ? 'flex' : 'none';\n const memoryCard = tc.querySelector('#ai-memory-card');\n if (memoryCard) memoryCard.style.display = e.target.checked ? 'block' : 'none';\n });\n\n // Toggle display of factsheet text area based on memory enabled checkbox\n const memoryEnabledCheckbox = tc.querySelector('#settings-ai-memory-enabled');\n const factsheetGroup = tc.querySelector('#settings-factsheet-group');\n if (memoryEnabledCheckbox && factsheetGroup) {\n memoryEnabledCheckbox.addEventListener('change', (e) => {\n factsheetGroup.style.display = e.target.checked ? 'block' : 'none';\n });\n }\n\n // Save Factsheet handler\n const btnSaveFactsheet = tc.querySelector('#btn-save-factsheet');\n if (btnSaveFactsheet) {\n btnSaveFactsheet.addEventListener('click', () => {\n const enabled = memoryEnabledCheckbox.checked;\n const factsheetTextarea = tc.querySelector('#settings-ai-factsheet');\n const factsheet = factsheetTextarea ? factsheetTextarea.value.trim() : '';\n\n localStorage.setItem(enabledKey, String(enabled));\n if (enabled) {\n localStorage.setItem(factsheetKey, factsheet);\n } else {\n localStorage.removeItem(factsheetKey);\n if (factsheetTextarea) {\n factsheetTextarea.value = '';\n }\n }\n\n window.dispatchEvent(new Event('storage'));\n showToast('AI memory preferences saved successfully', 'success');\n });\n }\n\n // Save handler\n tc.querySelector('#btn-save-ai').addEventListener('click', () => {\n const enabled = enabledCheckbox.checked;\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const systemPrompt = tc.querySelector('#ai-systemprompt').value.trim();\n\n if (isLocalMode && enabled && !apiKey) {\n showToast('API Key is required when enabling the AI assistant.', 'error');\n return;\n }\n\n const settings = store.getSettings();\n settings.ai = {\n enabled,\n apiKey,\n endpoint,\n model,\n systemPrompt\n };\n\n store.saveSettings(settings);\n showToast('AI settings saved successfully', 'success');\n });\n\n // Test handler\n tc.querySelector('#btn-test-ai').addEventListener('click', async () => {\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const statusEl = tc.querySelector('#test-status');\n const errEl = tc.querySelector('#test-error-details');\n\n const inElectron = !!(window.electronAPI && window.electronAPI.callDeepSeek);\n const hasEnvKey = !!(import.meta.env.VITE_DEEPSEEK_API_KEY);\n\n if (!apiKey && !inElectron && !hasEnvKey) {\n showToast('Enter an API key to test.', 'error');\n return;\n }\n\n statusEl.style.color = 'var(--text-secondary)';\n statusEl.textContent = 'Testing connection...';\n errEl.style.display = 'none';\n\n const startTime = Date.now();\n try {\n if (inElectron) {\n const data = await window.electronAPI.callDeepSeek({\n messages: [{ role: 'user', content: 'respond only with the word OK' }],\n endpoint,\n model,\n apiKey // Pass typed key if any\n });\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success (via Electron IPC)! Latency: ${latency}ms (Reply: \"${reply}\")`;\n } else {\n const res = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY}`\n },\n body: JSON.stringify({\n model: model || 'deepseek-chat',\n messages: [\n { role: 'user', content: 'respond only with the word OK' }\n ],\n max_tokens: 5\n })\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`HTTP ${res.status}: ${body}`);\n }\n\n const data = await res.json();\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success! Latency: ${latency}ms (Reply: \"${reply}\")`;\n }\n } catch (err) {\n console.error('AI test connection failed:', err);\n statusEl.style.color = 'var(--color-danger)';\n statusEl.textContent = 'Test failed';\n errEl.style.display = 'block';\n\n let errMsg = err.message;\n if (err.name === 'TypeError' && err.message.includes('Failed to fetch')) {\n errMsg = 'CORS Blocked or Offline. The browser blocked this cross-origin request. Please use a CORS proxy, an API gateway like OpenRouter, or check your internet connection.';\n }\n errEl.textContent = errMsg;\n }\n });\n }\n\n function renderCostCentersTab(tc) {\n const list = store.getAll('costCenters') || [];\n\n const renderTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:900px\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Cost Centers</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-cost-center\" style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span> Add Cost Center\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"padding-left:16px; width:120px\">Code</th>\n <th>Name</th>\n <th style=\"width:120px\">Status</th>\n <th style=\"text-align:right; padding-right:16px; width:180px\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${list.length === 0 ? `\n <tr>\n <td colspan=\"4\" style=\"text-align:center; padding:32px 16px; color:var(--text-secondary)\">\n No Cost Centers configured. Click \"Add Cost Center\" to create one.\n </td>\n </tr>\n ` : list.map(cc => `\n <tr>\n <td style=\"padding-left:16px; font-weight:600\">${escapeHTML(cc.code)}</td>\n <td>${escapeHTML(cc.name)}</td>\n <td>\n <span class=\"badge ${cc.active ? 'badge-success' : 'badge-neutral'}\">\n ${cc.active ? 'Active' : 'Inactive'}\n </span>\n </td>\n <td style=\"text-align:right; padding-right:16px\">\n <button class=\"btn btn-secondary btn-sm btn-edit-cc\" data-id=\"${cc.id}\" style=\"margin-right:6px\">Edit</button>\n <button class=\"btn btn-outline btn-sm btn-toggle-cc\" data-id=\"${cc.id}\">\n ${cc.active ? 'Disable' : 'Enable'}\n </button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-cost-center')?.addEventListener('click', () => openCostCenterModal());\n tc.querySelectorAll('.btn-edit-cc').forEach(btn => {\n btn.addEventListener('click', (e) => openCostCenterModal(e.target.dataset.id));\n });\n tc.querySelectorAll('.btn-toggle-cc').forEach(btn => {\n btn.addEventListener('click', async (e) => {\n const id = e.target.dataset.id;\n const cc = store.getById('costCenters', id);\n if (cc) {\n const newActive = !cc.active;\n await store.update('costCenters', id, { active: newActive });\n showToast(`Cost center ${cc.name} has been ${newActive ? 'enabled' : 'disabled'}.`, 'success');\n renderCostCentersTab(tc);\n }\n });\n });\n };\n\n renderTab();\n }\n\n function openCostCenterModal(editId = null) {\n let cc = editId ? store.getById('costCenters', editId) : { code: '', name: '', active: true };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\" style=\"margin-bottom:16px\">\n <label class=\"form-label\" style=\"display:block; margin-bottom:6px\">Cost Center Code</label>\n <input class=\"form-input\" id=\"cc-code\" value=\"${escapeHTML(cc.code)}\" placeholder=\"e.g. ELEC\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\" style=\"margin-bottom:16px\">\n <label class=\"form-label\" style=\"display:block; margin-bottom:6px\">Cost Center Name</label>\n <input class=\"form-input\" id=\"cc-name\" value=\"${escapeHTML(cc.name)}\" placeholder=\"e.g. Electrical Services\" style=\"width:100%\" />\n </div>\n <div class=\"form-group\">\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer\">\n <input type=\"checkbox\" id=\"cc-active\" ${cc.active ? 'checked' : ''} style=\"width:16px; height:16px; margin:0\" />\n <span>Active</span>\n </label>\n </div>\n `;\n\n showModal({\n title: editId ? 'Edit Cost Center' : 'Add Cost Center',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-cc', onClick: async (c) => {\n const code = document.getElementById('cc-code').value.trim().toUpperCase();\n const name = document.getElementById('cc-name').value.trim();\n const active = document.getElementById('cc-active').checked;\n\n if (!code) { showToast('Code required', 'error'); return; }\n if (!name) { showToast('Name required', 'error'); return; }\n\n try {\n const updates = { code, name, active };\n if (editId) {\n await store.update('costCenters', editId, updates);\n showToast('Cost Center updated successfully', 'success');\n } else {\n await store.create('costCenters', updates);\n showToast('Cost Center created successfully', 'success');\n }\n c();\n const tc = container.querySelector('#settings-content');\n renderCostCentersTab(tc);\n } catch (err) {\n console.error('Error saving cost center:', err);\n showToast('Failed to save cost center: ' + err.message, 'error');\n }\n }}\n ]\n });\n }\n\n render();\n }\n\n\n\n"}, "bdc84736467fd6932e05b70827dc17e46dad7016": {"t": 1785754182.5587523, "n": 246459, "h": "e469e614264c044f", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData, seedData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\nimport { applyTheme, THEMES } from '../utils/theme.js';\nimport { storageGet, storageSet } from '../utils/tauriStore.js';\n\n// Compress uploaded images using Canvas to avoid huge Base64 data payloads\nfunction compressImage(dataUrl, maxWidth, maxHeight) {\n return new Promise((resolve) => {\n if (!dataUrl) {\n resolve(dataUrl);\n return;\n }\n // SVG vectors are already lightweight; do not compress them\n if (dataUrl.startsWith('data:image/svg+xml')) {\n resolve(dataUrl);\n return;\n }\n const img = new Image();\n img.onload = () => {\n let width = img.width;\n let height = img.height;\n\n if (width > height) {\n if (width > maxWidth) {\n height = Math.round((height * maxWidth) / width);\n width = maxWidth;\n }\n } else {\n if (height > maxHeight) {\n width = Math.round((width * maxHeight) / height);\n height = maxHeight;\n }\n }\n\n const canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n\n const ctx = canvas.getContext('2d');\n ctx.drawImage(img, 0, 0, width, height);\n\n const isPng = dataUrl.includes('image/png');\n const format = isPng ? 'image/png' : 'image/jpeg';\n const quality = isPng ? undefined : 0.85;\n\n resolve(canvas.toDataURL(format, quality));\n };\n img.onerror = () => {\n resolve(dataUrl);\n };\n img.src = dataUrl;\n });\n}\n\n// Helper to hash password using SHA-256 Web Crypto API\nasync function hashPassword(password) {\n const msgBuffer = new TextEncoder().encode(password);\n const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);\n const hashArray = Array.from(new Uint8Array(hashBuffer));\n return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');\n}\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n const localDeploymentType = settings.localDeploymentType || 'single_user';\n\n const isUsersDisabled = isLocalMode && localDeploymentType === 'single_user';\n const isPortalDisabled = isLocalMode;\n const isFolderSyncDisabled = !isLocalMode;\n\n if (isUsersDisabled && activeTab === 'users') {\n activeTab = 'company';\n }\n if (isPortalDisabled && activeTab === 'portal') {\n activeTab = 'company';\n }\n if (isFolderSyncDisabled && activeTab === 'folder_sync') {\n activeTab = 'company';\n }\n \n const isAIAssistantDisabled = isLocalMode;\n if (isAIAssistantDisabled && activeTab === 'ai_assistant') {\n activeTab = 'company';\n }\n\n const openMigrationModal = () => {\n const modalContent = document.createElement('div');\n modalContent.innerHTML = `\n <form id=\"convert-cloud-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Cloud Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a local/offline account. All data will be migrated to the secure cloud database.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"migrate-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"Company Name\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"migrate-admin-name\" required placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Phone Number</label>\n <input class=\"form-input\" id=\"migrate-admin-phone\" required placeholder=\"e.g. 0412 345 678\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Email Address (Username)</label>\n <input class=\"form-input\" type=\"email\" id=\"migrate-admin-email\" required placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Password</label>\n <input class=\"form-input\" type=\"password\" id=\"migrate-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"migration-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"migration-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-migrate-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-migrate-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" id=\"submit-icon\" style=\"font-size:18px;\">cloud_done</span>\n <span id=\"submit-text\">Register & Start Migration</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: 'Register & Migrate to Cloud',\n content: modalContent,\n size: 'modal-md'\n });\n\n modalContent.querySelector('#btn-migrate-cancel').addEventListener('click', close);\n\n const form = modalContent.querySelector('#convert-cloud-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n\n const errorEl = modalContent.querySelector('#migration-error');\n const errorTextEl = modalContent.querySelector('#migration-error-text');\n const submitBtn = modalContent.querySelector('#btn-migrate-submit');\n const cancelBtn = modalContent.querySelector('#btn-migrate-cancel');\n const submitText = modalContent.querySelector('#submit-text');\n const submitIcon = modalContent.querySelector('#submit-icon');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n submitText.textContent = 'Migrating to Cloud...';\n submitIcon.className = 'material-icons-outlined spinner';\n submitIcon.textContent = 'sync';\n submitIcon.style.animation = 'spin 1s linear infinite';\n\n const companyName = modalContent.querySelector('#migrate-company-name').value.trim();\n const adminName = modalContent.querySelector('#migrate-admin-name').value.trim();\n const adminPhone = modalContent.querySelector('#migrate-admin-phone').value.trim();\n const email = modalContent.querySelector('#migrate-admin-email').value.trim();\n const password = modalContent.querySelector('#migrate-admin-password').value;\n\n try {\n const settings = store.getSettings();\n settings.name = companyName;\n await store.saveSettings(settings);\n\n const { supabase } = await import('../utils/supabase.js');\n\n const { data: authData, error: authErr } = await supabase.auth.signUp({\n email,\n password,\n options: {\n data: {\n name: adminName,\n phone: adminPhone\n }\n }\n });\n if (authErr) throw authErr;\n\n if (!authData.user) {\n throw new Error('Verification required or signup was blocked. Check your email inbox.');\n }\n\n const { data: companyId, error: rpcError } = await supabase.rpc('create_company_and_admin', {\n user_id: authData.user.id,\n company_name: companyName,\n admin_name: adminName,\n admin_phone: adminPhone\n });\n if (rpcError) throw rpcError;\n\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n await store.migrateLocalToCloud(companyId, authData.user.id);\n\n if (activeAccountId) {\n const localAccountsKey = 'relay_accounts';\n let localAccounts = [];\n try {\n const stored = localStorage.getItem(localAccountsKey);\n if (stored) {\n localAccounts = JSON.parse(stored);\n }\n } catch (e) {\n console.error('Error reading local accounts:', e);\n }\n localAccounts = localAccounts.filter(a => a.id !== activeAccountId);\n localStorage.setItem(localAccountsKey, JSON.stringify(localAccounts));\n\n store.deleteLocalAccountData(activeAccountId);\n }\n\n const { data: profile, error: profileErr } = await supabase\n .from('profiles')\n .select('*')\n .eq('id', authData.user.id)\n .single();\n if (profileErr) throw profileErr;\n\n const user = {\n id: profile.id,\n companyId: profile.company_id,\n name: profile.name,\n role: profile.role,\n userTypeName: 'Admin',\n userTypeId: `${profile.company_id}_ut_admin`,\n color: profile.color || '#FF5C00',\n theme: 'light'\n };\n\n localStorage.setItem('currentUser', JSON.stringify(user));\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Migration completed successfully! Redirecting...', 'success');\n close();\n\n setTimeout(() => {\n window.location.hash = '#/';\n window.location.reload();\n }, 1500);\n\n } catch (err) {\n console.error('Migration failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during migration.';\n errorEl.style.display = 'flex';\n\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n submitText.textContent = 'Register & Start Migration';\n submitIcon.className = 'material-icons-outlined';\n submitIcon.textContent = 'cloud_done';\n submitIcon.style.animation = '';\n }\n });\n };\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''} ${isUsersDisabled ? 'disabled-local' : ''}\" data-tab=\"users\" ${isUsersDisabled ? 'data-tooltip=\"Requires Cloud Account or Local Server\" data-tooltip-pos=\"top\"' : ''}>Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''} ${isPortalDisabled ? 'disabled-local' : ''}\" data-tab=\"portal\" ${isPortalDisabled ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''}>Customer Portal</button>\n <button class=\"tab ${activeTab === 'invoices_quotes' ? 'active' : ''}\" data-tab=\"invoices_quotes\">Quotes &amp; Invoices</button>\n <button class=\"tab ${activeTab === 'folder_sync' ? 'active' : ''} ${isFolderSyncDisabled ? 'disabled-local' : ''}\" data-tab=\"folder_sync\" ${isFolderSyncDisabled ? 'data-tooltip=\"Requires Local Folder Storage\" data-tooltip-pos=\"top\"' : ''}>Folder Sync</button>\n <button class=\"tab ${activeTab === 'ai_assistant' ? 'active' : ''} ${isAIAssistantDisabled ? 'disabled-local' : ''}\" data-tab=\"ai_assistant\" ${isAIAssistantDisabled ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''}>AI Assistant</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n if (tab.classList.contains('disabled-local')) return;\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'folder_sync') {\n renderFolderSyncTab(tc);\n return;\n }\n\n if (activeTab === 'ai_assistant') {\n renderAIAssistantTab(tc);\n return;\n }\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n\n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Standard Logo (Large) using Canvas to ~600x300 max bounding box\n compressImage(re.target.result, 600, 300).then((compressed) => {\n pendingLogo = compressed;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Small/Shrunk Logo using Canvas to ~200x100 max bounding box\n compressImage(re.target.result, 200, 100).then((compressed) => {\n pendingLogoSmall = compressed;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n\n\n tc.querySelector('#btn-save-company').addEventListener('click', async () => {\n const saveBtn = tc.querySelector('#btn-save-company');\n const originalHtml = saveBtn.innerHTML;\n saveBtn.disabled = true;\n saveBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving Changes...';\n\n try {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n await store.saveSettings(settings);\n showToast('Company information saved successfully to database', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n render();\n } catch (err) {\n console.error('Error saving company profile settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n saveBtn.disabled = false;\n saveBtn.innerHTML = originalHtml;\n }\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n const pendingResets = store.getAll('passwordResetRequests') || [];\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div style=\"background:rgba(59, 130, 246, 0.1); border-left:4px solid #3b82f6; padding:12px 16px; margin-bottom:var(--space-md); border-radius:4px; font-size:13px; color:#f8fafc; display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:#3b82f6; font-size:18px;\">info</span>\n <span>Your company login code is <strong style=\"color:#60a5fa\">${companySlug}</strong>. Technicians log in using <strong style=\"color:#60a5fa\">username@${companySlug}</strong>.</span>\n </span>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Username</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n ${ut?.id !== 'ut_admin' ? `\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n ` : ''}\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\">\n <h4 style=\"margin:0\">Pending Password Reset Requests</h4>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Username/Email</th>\n <th>Requested On</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:24px\">No pending reset requests</td></tr>' : ''}\n ${pendingResets.filter(r => r.status === 'pending').map(r => {\n const tech = techs.find(t => t.id === r.technician_id) || {};\n const requestedAt = r.requested_at ? new Date(r.requested_at).toLocaleString() : 'Unknown';\n return `\n <tr>\n <td class=\"font-medium\">${tech.name || 'Unknown'}</td>\n <td class=\"text-secondary\">${r.employee_id || tech.username || ''}</td>\n <td class=\"text-tertiary\">${requestedAt}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-approve-reset\" data-id=\"${r.id}\" data-tech-id=\"${tech.id}\">Approve &amp; Reset</button>\n <button class=\"btn btn-sm btn-ghost text-danger btn-deny-reset\" data-id=\"${r.id}\">Deny</button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n ${isLocalMode ? '' : `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `}\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => {\n openUserModal();\n });\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-approve-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const techId = e.currentTarget.dataset.techId;\n const t = store.getById('technicians', techId);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <p>Approve password reset request for <strong>${t.name}</strong>? Please enter their new password below:</p>\n <div class=\"form-group\" style=\"margin-top:12px\">\n <label class=\"form-label\">New Password</label>\n <input type=\"password\" id=\"admin-reset-pwd-input\" class=\"form-input\" placeholder=\"Min. 6 characters\" minlength=\"6\" autofocus />\n </div>\n `;\n showModal({\n title: 'Approve Password Reset',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reset Password', className: 'btn-primary', onClick: c => {\n const pwdInput = document.getElementById('admin-reset-pwd-input');\n const newPwd = pwdInput ? pwdInput.value : '';\n if (!newPwd || newPwd.length < 6) {\n showToast('Password must be at least 6 characters.', 'error');\n return;\n }\n \n // Update technician password\n store.update('technicians', techId, { password: newPwd });\n \n // Approve the request\n store.update('passwordResetRequests', id, { status: 'approved', updated_at: new Date().toISOString() });\n \n showToast(`Password updated for ${t.name}`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-deny-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n store.update('passwordResetRequests', id, { status: 'denied', updated_at: new Date().toISOString() });\n showToast('Password reset request denied', 'info');\n renderContent();\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax Rates</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\" style=\"display:flex; flex-direction:column; gap:8px\">\n <label class=\"form-label\" style=\"display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:0\">\n <input type=\"checkbox\" id=\"tax-enabled\" style=\"width:16px; height:16px; margin:0\" ${settings.taxEnabled !== false ? 'checked' : ''} />\n Enable GST / Sales Tax\n </label>\n <div style=\"display:${settings.taxEnabled !== false ? 'flex' : 'none'}; align-items:center; gap:8px; margin-top:4px\" id=\"tax-rate-container\">\n <input class=\"form-input\" id=\"tax-rate\" type=\"number\" value=\"${settings.taxRate !== undefined ? settings.taxRate : 10}\" style=\"width:100px\" min=\"0\" max=\"100\" step=\"0.1\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // Toggle tax rate container visibility\n tc.querySelector('#tax-enabled')?.addEventListener('change', (e) => {\n const container = tc.querySelector('#tax-rate-container');\n if (container) {\n container.style.display = e.target.checked ? 'flex' : 'none';\n }\n });\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#save-tax-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const taxEnabled = tc.querySelector('#tax-enabled').checked;\n const taxRate = parseFloat(tc.querySelector('#tax-rate').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.taxEnabled = taxEnabled;\n settings.taxRate = taxRate;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n await store.saveSettings(settings);\n showToast('Financial and Rate settings saved successfully', 'success');\n renderContent();\n } catch (err) {\n console.error('Error saving tax and rate settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-portal-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n await store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving customer portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'system') {\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n const currentTheme = (currentUser && currentUser.id) ? (currentUser.theme || localStorage.getItem(`simpro_theme_${currentUser.id}`) || 'light') : 'light';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Left Column -->\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg)\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n </p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n ${currentUser.role === 'admin' ? `\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed a complete demonstration dataset\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed Demonstration Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n <button class=\"btn btn-danger\" id=\"btn-delete-company\" data-tooltip=\"Permanently delete the entire company profile and all data\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; background:var(--color-danger); border-color:var(--color-danger); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span> Delete Company Profile\n </button>\n\n ${isLocalMode ? `\n <div style=\"margin-top:16px; padding-top:12px; border-top:1px dashed rgba(220, 38, 38, 0.2)\">\n <h6 style=\"color:var(--color-warning); margin-bottom:8px; font-weight:600; display:flex; align-items:center; gap:4px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">warning</span> Irreversible Profile Conversions\n </h6>\n <p style=\"font-size:11px; color:var(--text-secondary); margin-bottom:12px; line-height:1.3;\">\n Convert your deployment type. These transitions are permanent and one-way.\n </p>\n ${localDeploymentType === 'single_user' ? `\n <button class=\"btn\" id=\"btn-convert-multiuser\" style=\"width:100%; justify-content:center; margin-bottom:12px; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">lan</span> Convert to Multi-User Local Network Sync\n </button>\n ` : ''}\n <button class=\"btn\" id=\"btn-convert-cloud-action\" style=\"width:100%; justify-content:center; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">cloud_upload</span> Convert to Cloud Sync\n </button>\n </div>\n ` : ''}\n </div>\n ` : '<div style=\"font-size:12.5px; color:var(--text-tertiary)\">Administrative actions are restricted to company administrators.</div>'}\n </div>\n </div>\n </div>\n\n <!-- Local Data Backup -->\n ${!isLocalMode && currentUser.role === 'admin' ? `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary)\">backup</span>\n <h4 style=\"margin:0;\">Local Data Backup</h4>\n </div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:12px;\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm); line-height:1.4; margin:0;\">\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n </p>\n <div id=\"backup-status-container\"></div>\n </div>\n </div>\n ` : ''}\n </div>\n\n <!-- Right Column -->\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">System Color Theme</label>\n <select class=\"form-select\" id=\"system-theme-select\" style=\"width:100%\">\n ${Object.entries(THEMES).map(([key, val]) => `\n <option value=\"${key}\" ${currentTheme === key ? 'selected' : ''}>${val.name}</option>\n `).join('')}\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Select a color profile and visual background effect for your user interface.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', async (e) => {\n const select = e.target;\n select.disabled = true;\n try {\n const settings = store.getSettings();\n settings.tooltipPreference = select.value;\n await store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved successfully', 'success');\n } catch (err) {\n console.error('Error saving tooltip preference:', err);\n showToast('Failed to save preferences: ' + (err.message || err), 'error');\n } finally {\n select.disabled = false;\n }\n });\n\n tc.querySelector('#system-theme-select')?.addEventListener('change', (e) => {\n applyTheme(e.target.value, true);\n showToast('System theme updated successfully', 'success');\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a complete demonstration dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load a complete, realistic demonstration dataset representing an active trade business (5 customers, 5 quotes/jobs per customer, 5 assets per customer, 5 materials, 5 users/timesheets, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for testing and walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed Demonstration Data\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Demo Data\",\n className: \"btn-primary\",\n onClick: async (close) => {\n close();\n showToast('Seeding demonstration data...', 'info');\n await seedMinimalData();\n showToast('Demonstration data seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-delete-company')?.addEventListener('click', () => {\n // Modal Warning 1 of 2\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">warning</span>\n <span>You are about to permanently delete the entire company profile for <strong>${escapeHTML(store.getSettings().name || '')}</strong> and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).</span>\n </div>\n <p style=\"font-size:12.5px; color:var(--text-secondary); margin-bottom:12px\">\n This action is final, irreversible, and cannot be undone under any circumstances.\n </p>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Do you want to proceed to password confirmation?</p>\n `;\n\n showModal({\n title: \"Delete Company Profile - Step 1 of 2\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (closeModal) => closeModal()\n },\n {\n label: \"Proceed to Confirm\",\n className: \"btn-danger\",\n onClick: (closeModal) => {\n closeModal();\n \n // Modal Warning 2 of 2\n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <form id=\"delete-company-confirm-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <p style=\"margin-bottom:8px; font-weight:600; color:var(--color-danger)\">FINAL CONFIRMATION (2 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">gavel</span>\n <span>To authorize the permanent destruction of this company, you must enter the Administrator password.</span>\n </div>\n\n <div class=\"form-group\" style=\"margin-top:12px;\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Password</label>\n <input class=\"form-input\" type=\"password\" id=\"delete-confirm-password\" required placeholder=\"Enter admin password to proceed\" />\n </div>\n\n <div id=\"delete-confirm-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"delete-confirm-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-delete-abort\">Abort Deletion</button>\n <button type=\"submit\" class=\"btn btn-danger\" id=\"btn-delete-confirm-submit\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span>\n <span>Permanently Delete Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close: close2 } = showModal({\n title: \"⚠️ Confirm Company Deletion - Step 2 of 2\",\n content: content2,\n size: \"modal-md\"\n });\n\n content2.querySelector('#btn-delete-abort').addEventListener('click', close2);\n\n const form2 = content2.querySelector('#delete-company-confirm-form');\n form2.addEventListener('submit', async (ev) => {\n ev.preventDefault();\n\n const errorEl = content2.querySelector('#delete-confirm-error');\n const errorTextEl = content2.querySelector('#delete-confirm-error-text');\n const submitBtn = content2.querySelector('#btn-delete-confirm-submit');\n const abortBtn = content2.querySelector('#btn-delete-abort');\n const passwordInput = content2.querySelector('#delete-confirm-password').value;\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n abortBtn.disabled = true;\n const origSubmitText = submitBtn.innerHTML;\n submitBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Deleting...';\n\n try {\n let passwordVerified = false;\n\n if (isLocalMode) {\n if (localDeploymentType === 'single_user') {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acct = storedAccounts.find(a => a.id === activeAccountId);\n if (acct && acct.hasPassword) {\n const hashedInput = await hashPassword(passwordInput);\n if (hashedInput === acct.passwordHash) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n // No password set\n passwordVerified = true;\n }\n } else {\n // local_multiuser\n const techs = store.getAll('technicians') || [];\n const currentTech = techs.find(t => t.id === currentUser.id);\n const expectedPassword = currentTech ? currentTech.password : '123456';\n if (passwordInput === expectedPassword) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n }\n } else {\n // Cloud mode\n const { supabase } = await import('../utils/supabase.js');\n const { data: { user } } = await supabase.auth.getUser();\n if (user && user.email) {\n const { error } = await supabase.auth.signInWithPassword({\n email: user.email,\n password: passwordInput\n });\n if (!error) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n throw new Error('Could not retrieve logged-in cloud user credentials.');\n }\n }\n\n if (passwordVerified) {\n // 1. If cloud mode, delete the company row in Supabase\n if (!isLocalMode) {\n const { supabase } = await import('../utils/supabase.js');\n const { error: deleteErr } = await supabase.from('companies').delete().eq('id', store.companyId);\n if (deleteErr) {\n throw new Error('Failed to delete cloud company: ' + deleteErr.message);\n }\n }\n\n // 2. Trigger store.clearAll()\n store.clearAll();\n\n // 3. Clear local account launcher keys if local mode\n if (isLocalMode) {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const updatedAccounts = storedAccounts.filter(a => a.id !== activeAccountId);\n await storageSet('relay_accounts', updatedAccounts);\n }\n }\n\n // 4. Remove session user & active account\n localStorage.removeItem('currentUser');\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Company profile deleted successfully.', 'success');\n close2();\n\n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n } catch (err) {\n console.error('Company deletion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during verification.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n abortBtn.disabled = false;\n submitBtn.innerHTML = origSubmitText;\n }\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-convert-multiuser')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n\n const techs = store.getAll('technicians') || [];\n const companyId = store.companyId || '';\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const existingAdmin = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n const currentTechName = existingAdmin ? existingAdmin.name : (currentUser.name && currentUser.name !== 'Local Admin' ? currentUser.name : '');\n const currentTechUsernameOrEmail = existingAdmin ? (existingAdmin.email || existingAdmin.username || '') : '';\n\n content.innerHTML = `\n <form id=\"convert-multiuser-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Multi-User Local Network Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a single-user Local Admin profile.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"multiuser-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"e.g. Apex Power Services\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"multiuser-admin-name\" required value=\"${escapeHTML(currentTechName)}\" placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Email Address (Required)</label>\n <input class=\"form-input\" type=\"email\" id=\"multiuser-admin-email\" required value=\"${escapeHTML(currentTechUsernameOrEmail)}\" placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Admin Password</label>\n <input class=\"form-input\" type=\"password\" id=\"multiuser-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"multiuser-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"multiuser-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-multiuser-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-multiuser-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">lan</span>\n <span>Convert Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: \"Convert to Multi-User Local Sync\",\n content: content,\n size: \"modal-md\"\n });\n\n content.querySelector('#btn-multiuser-cancel').addEventListener('click', close);\n\n const form = content.querySelector('#convert-multiuser-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n const errorEl = content.querySelector('#multiuser-error');\n const errorTextEl = content.querySelector('#multiuser-error-text');\n const submitBtn = content.querySelector('#btn-multiuser-submit');\n const cancelBtn = content.querySelector('#btn-multiuser-cancel');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n\n const businessName = content.querySelector('#multiuser-company-name').value.trim();\n const adminName = content.querySelector('#multiuser-admin-name').value.trim();\n const adminEmail = content.querySelector('#multiuser-admin-email').value.trim();\n const adminPassword = content.querySelector('#multiuser-admin-password').value;\n\n try {\n if (!businessName || !adminName || !adminEmail || !adminPassword) {\n throw new Error('All fields are required.');\n }\n if (adminPassword.length < 6) {\n throw new Error('Password must be at least 6 characters.');\n }\n\n // 1. Update settings\n const settings = store.getSettings();\n settings.name = businessName;\n settings.localDeploymentType = 'multi_user';\n await store.saveSettings(settings);\n\n // 2. Update launcher accounts businessName\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acctIndex = storedAccounts.findIndex(a => a.id === activeAccountId);\n if (acctIndex !== -1) {\n storedAccounts[acctIndex].businessName = businessName;\n await storageSet('relay_accounts', storedAccounts);\n }\n }\n\n // 3. Create or update admin user in technicians\n const companyId = store.companyId;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const techs = store.getAll('technicians') || [];\n let adminTech = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n if (!adminTech) {\n adminTech = {\n id: `${companyId}_tech_admin_created`,\n role: 'Admin',\n color: '#FF5C00',\n userTypeId: adminTypeId,\n payRate: 100.00,\n phone: ''\n };\n techs.push(adminTech);\n }\n\n adminTech.name = adminName;\n adminTech.username = adminEmail.split('@')[0];\n adminTech.email = adminEmail;\n adminTech.password = adminPassword;\n\n store.save('technicians', techs);\n\n // 4. Update currentUser session in localStorage\n const localUser = {\n id: adminTech.id,\n companyId: companyId,\n name: adminTech.name,\n role: 'admin',\n userTypeName: 'Admin',\n userTypeId: adminTech.userTypeId,\n color: adminTech.color || '#FF5C00',\n theme: 'light'\n };\n localStorage.setItem('currentUser', JSON.stringify(localUser));\n\n showToast('Converted to Multi-User Local Network Sync. Reloading...', 'success');\n close();\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n setTimeout(() => window.location.reload(), 1200);\n } catch (err) {\n console.error('Conversion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during conversion.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n }\n });\n });\n\n tc.querySelector('#btn-convert-cloud-action')?.addEventListener('click', () => {\n openMigrationModal();\n });\n\n const renderBackupStatus = () => {\n const bsc = tc.querySelector('#backup-status-container');\n if (!bsc) return;\n\n const hasHandle = !!store.backupDirHandle;\n const isPermissionGranted = store.backupDirPermissionGranted;\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const formattedLastBackup = lastBackup ? new Date(lastBackup).toLocaleString() : 'Never';\n const isSupported = typeof window !== 'undefined' && window.showDirectoryPicker;\n\n if (!isSupported) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-danger); line-height:1.4;\">\n <strong style=\"display:block; margin-bottom:4px;\">Browser Local Folder Access Unsupported</strong>\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n </div>\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:12px; border-radius:6px; margin-bottom:12px; color:var(--text-secondary); display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary);\">folder_off</span>\n <div style=\"font-size:12.5px;\">No backup folder configured.</div>\n </div>\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">folder</span> Choose Backup Folder...\n </button>\n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-warning); line-height:1.4; margin-bottom:12px;\">\n <strong>Permission Required</strong><br/>\n Access permission to <strong>${escapeHTML(store.backupDirHandle.name)}</strong> has expired. Please re-authorize.\n </div>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-warning\" id=\"btn-backup-auth\" style=\"flex:1; justify-content:center;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize & Backup\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n `;\n } else {\n bsc.innerHTML = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-success); line-height:1.4; margin-bottom:12px;\">\n <strong>Backup Configured</strong><br/>\n Folder: <strong>${escapeHTML(store.backupDirHandle.name)}</strong><br/>\n Last Backup: <strong>${formattedLastBackup}</strong>\n </div>\n <div style=\"display:flex; flex-direction:column; gap:8px;\">\n <button class=\"btn btn-primary\" id=\"btn-backup-now\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">backup</span> Backup Now\n </button>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"flex:1; justify-content:center; border:1px solid var(--border-color);\">\n <span class=\"material-icons-outlined\">folder</span> Change Folder...\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n </div>\n `;\n }\n\n // Attach event listeners for backup buttons inside container\n bsc.querySelector('#btn-backup-pick')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setBackupDirectory(handle);\n showToast('Backup directory selected successfully.', 'success');\n showToast('Running initial backup...', 'info');\n await store.backupToFolder(handle);\n showToast('Backup completed successfully!', 'success');\n renderBackupStatus();\n } catch (err) {\n console.error('Failed to select backup directory:', err);\n if (err.name !== 'AbortError') {\n showToast('Failed to configure backup: ' + err.message, 'error');\n }\n }\n });\n\n bsc.querySelector('#btn-backup-auth')?.addEventListener('click', async () => {\n const granted = await store.verifyBackupDirPermission(true);\n if (granted) {\n showToast('Permission re-authorized. Backing up...', 'info');\n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n showToast('Backup failed: ' + err.message, 'error');\n }\n } else {\n showToast('Failed to acquire write permission.', 'error');\n }\n renderBackupStatus();\n });\n\n bsc.querySelector('#btn-backup-now')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Backing up...';\n \n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Backup failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n renderBackupStatus();\n }\n });\n\n bsc.querySelector('#btn-backup-disconnect')?.addEventListener('click', async () => {\n await store.setBackupDirectory(null);\n showToast('Backup folder disconnected.', 'info');\n renderBackupStatus();\n });\n };\n\n if (!isLocalMode && currentUser.role === 'admin') {\n renderBackupStatus();\n }\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', username: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Username</label>\n <input class=\"form-input\" id=\"u-username\" value=\"${t.username || (t.email ? t.email.split('@')[0] : '')}\" ${editId ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''} placeholder=\"e.g. joshua\" />\n ${!editId ? `\n <div style=\"font-size: 11px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500;\">\n Company login code is: <strong style=\"color: var(--color-primary)\">${companySlug}</strong>. User will log in with <strong style=\"color: var(--color-primary)\">username@${companySlug}</strong>\n </div>\n ` : ''}\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">${editId ? 'Reset Password (leave blank to keep current)' : 'Temporary Password (assigned for worker login)'}</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"${editId ? '••••••••' : 'Min. 6 characters'}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\" ${(t.userTypeId === 'ut_admin' || (t.userTypeId && t.userTypeId.endsWith('_ut_admin'))) ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''}>\n <option value=\"\">-- Select --</option>\n ${userTypes\n .filter(ut => (!ut.id.endsWith('_ut_admin') && ut.id !== 'ut_admin') || t.userTypeId === ut.id)\n .map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value.trim();\n const username = document.getElementById('u-username').value.trim();\n const role = document.getElementById('u-role').value.trim();\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = document.getElementById('u-password')?.value || '';\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!username) { showToast('Username required', 'error'); return; }\n if (username.includes('@')) { showToast('Username cannot contain @ symbol', 'error'); return; }\n \n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (password && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n const updates = { name, username, role, userTypeId, color, payRate };\n if (password) {\n updates.password = password;\n }\n\n if (editId) {\n await store.update('technicians', editId, updates);\n } else {\n await store.create('technicians', updates);\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = async () => {\n const btn = tc.querySelector('#btn-save-materials');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n await store.saveSettings(updatedSettings);\n showToast('Material settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving material settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-00023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-00129',\n originalQuoteNumber: 'Q-00412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' },\n ballet: { name: 'Ballet Pointe', preset: 'ballet', accentColor: '#e58799', headerBg: '#5c2c36', accentTint: '#fff5f6', fontFamily: 'serif' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-company-name\" style=\"width:16px; height:16px\" ${dt.hideCompanyName ? 'checked' : ''} />\n <label for=\"theme-hide-company-name\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide company name text (use logo only)</label>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Variation</label>\n <select class=\"form-select\" id=\"theme-logo-source\" style=\"width:100%\">\n <option value=\"large\" ${dt.logoSource === 'large' ? 'selected' : ''}>Standard Logo (Large)</option>\n <option value=\"small\" ${dt.logoSource === 'small' ? 'selected' : ''}>Small / Shrunk Logo (Small)</option>\n </select>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:8px 0\"></div>\n <div class=\"form-group ${isLocalMode ? 'disabled-local' : ''}\" ${isLocalMode ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''} style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-brand-logo\" style=\"width:16px; height:16px\" ${dt.hideBrandLogo ? 'checked' : ''} ${isLocalMode ? 'disabled' : ''} />\n <label for=\"theme-hide-brand-logo\" class=\"form-label\" style=\"margin:0; cursor:${isLocalMode ? 'not-allowed' : 'pointer'}\">Remove Relay-dispatch brand logo from document footers</label>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Hide Company Name toggle\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) {\n hideCompanyNameChk.addEventListener('change', () => {\n dt.hideCompanyName = hideCompanyNameChk.checked;\n updatePreview();\n });\n }\n\n // Logo Variation toggle\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) {\n logoSourceSelect.addEventListener('change', (e) => {\n dt.logoSource = e.target.value;\n updatePreview();\n });\n }\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Hide Brand Logo toggle\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) {\n hideBrandLogoChk.addEventListener('change', () => {\n dt.hideBrandLogo = hideBrandLogoChk.checked;\n updatePreview();\n });\n }\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-theme-save');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n await store.saveSettings(freshSettings);\n showToast('Document customization settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving theme settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n hideCompanyName: false,\n hideBrandLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) hideCompanyNameChk.checked = !!dt.hideCompanyName;\n\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) logoSourceSelect.value = dt.logoSource || 'large';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) hideBrandLogoChk.checked = !!dt.hideBrandLogo;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n const logoUrl = dt.logoSource === 'small' ? (settings.logoSmall || settings.logo) : (settings.logo || settings.logoSmall);\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.src = logoUrl || '';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // Company Name visibility\n const companyNameEl = frameDoc.querySelector('.pdf-company-name');\n if (companyNameEl) {\n companyNameEl.style.display = dt.hideCompanyName ? 'none' : 'block';\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n\n const footerBrandingEl = frameDoc.querySelector('.pdf-footer-branding');\n if (footerBrandingEl) {\n const hideBrandLogo = isLocalMode ? false : !!dt.hideBrandLogo;\n footerBrandingEl.style.display = hideBrandLogo ? 'none' : 'flex';\n }\n }\n\n renderMarkup();\n }\n\n function renderFolderSyncTab(tc) {\n const isSupported = typeof window !== 'undefined' && (\n (window.indexedDB && window.showDirectoryPicker) ||\n (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem)\n );\n\n const isCapacitor = typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem;\n\n const activeAccountId = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n const isLocalCompany = !!activeAccountId;\n\n function render() {\n console.log('[DEBUG Settings] render: isLocalCompany =', isLocalCompany, 'store.dirHandle =', store.dirHandle, 'permissionGranted =', store.folderSyncPermissionGranted);\n const isEnabled = isLocalCompany ? true : store.folderSyncEnabled;\n const isPermissionGranted = store.folderSyncPermissionGranted;\n const hasHandle = !!store.dirHandle;\n\n let statusHtml = '';\n if (!isSupported) {\n statusHtml = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:16px; border-radius:4px; color:var(--color-danger); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:8px; font-weight:600; margin-bottom:4px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Browser Directory Access Unsupported</span>\n </div>\n <p style=\"font-size:var(--font-size-sm); margin:0; line-height:1.4;\">\n Your current browser does not support local folder access. To enable direct folder synchronization, please run this app in a Chromium-based browser (Chrome, Edge, Opera, or as a compiled native app). High-capacity IndexedDB storage remains active.\n </p>\n </div>\n `;\n } else if (!isEnabled) {\n statusHtml = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:16px; border-radius:6px; color:var(--text-secondary); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px; color:var(--text-tertiary);\">folder_off</span>\n <div>\n <div style=\"font-weight:600; color:var(--text-primary); margin-bottom:2px;\">Folder Synchronization Inactive</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4;\">All data is currently stored in your browser's private IndexedDB database sandbox.</p>\n </div>\n </div>\n `;\n } else if (isCapacitor) {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">cloud_done</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Capacitor Direct Folder Sync Active</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Data is synchronizing directly to the application's native <strong>Documents/RelayDispatchData</strong> directory on your iPad/device.\n </p>\n </div>\n </div>\n `;\n } else if (!hasHandle) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">warning</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">No Directory Selected</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">Please select a directory folder on your computer to begin syncing.</p>\n </div>\n </div>\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:12px; margin-bottom:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">lock</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Access Permission Suspended</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n The browser requires re-authorization to read/write files in <strong>${escapeHTML(store.dirHandle.name)}</strong>.\n </p>\n </div>\n </div>\n <button class=\"btn btn-warning\" id=\"btn-reauthorize-dir\" style=\"margin-left:44px;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize Access\n </button>\n </div>\n `;\n } else {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">check_circle</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Folder Sync Active & Synchronized</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Active Folder: <strong>${escapeHTML(store.dirHandle.name)}</strong>. All data edits are writing dynamically.\n </p>\n </div>\n </div>\n `;\n }\n\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <!-- Folder Configuration -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Direct Directory Sync</h4></div>\n <div class=\"card-body\">\n ${statusHtml}\n\n <div class=\"form-group\" style=\"margin-bottom:var(--space-lg);\">\n <label class=\"form-label\" style=\"font-weight:600; margin-bottom:8px;\">Synchronization Toggle</label>\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\">\n <input type=\"checkbox\" id=\"toggle-folder-sync\" ${isEnabled ? 'checked' : ''} ${(!isSupported || isLocalCompany) ? 'disabled' : ''} style=\"width:20px; height:20px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\" />\n <div>\n <span style=\"font-weight:500;\">Enable Direct Local Folder Storage</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">\n ${isLocalCompany ? 'Mandatory for offline/local company profile storage.' : 'Saves all data records and attachments straight to your machine.'}\n </div>\n </div>\n </label>\n </div>\n\n ${isEnabled && isSupported && !isCapacitor ? `\n <div style=\"display:flex; gap:12px; flex-wrap:wrap; border-top:1px solid var(--border-color); padding-top:var(--space-lg);\">\n <button class=\"btn btn-secondary\" id=\"btn-pick-dir\">\n <span class=\"material-icons-outlined\">folder</span> Choose Sync Folder...\n </button>\n ${hasHandle && isPermissionGranted ? `\n <button class=\"btn btn-secondary\" id=\"btn-force-sync\" data-tooltip=\"Overwrite folder JSONs with current memory cache\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">sync</span> Sync Now\n </button>\n ${!isLocalCompany ? `\n <button class=\"btn btn-danger\" id=\"btn-disconnect-dir\">\n <span class=\"material-icons-outlined\">link_off</span> Disconnect Folder\n </button>\n ` : ''}\n ` : ''}\n </div>\n ` : ''}\n </div>\n </div>\n\n <!-- Instructions Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>How Self-Hosting Works</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p>\n By linking a local folder, you establish a <strong>serverless self-hosted data hub</strong>.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">storage</span>\n <span><strong>Readable Data:</strong> Your jobs, quotes, and company configurations are saved in <code>data/*.json</code> files.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">photo_library</span>\n <span><strong>Physical Attachments:</strong> Uploaded photos and PDF catalogs are saved as standard image/PDF files under the <code>documents/</code> subfolder.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">sync_alt</span>\n <span><strong>Cloud Backups:</strong> Select a folder synced to <strong>OneDrive, Google Drive, or Dropbox</strong> to automatically back up and sync your data to the cloud.</span>\n </div>\n </div>\n </div>\n </div>\n `;\n\n // Event Listeners\n const toggle = tc.querySelector('#toggle-folder-sync');\n toggle?.addEventListener('change', async (e) => {\n if (isLocalCompany) {\n e.preventDefault();\n toggle.checked = true;\n return;\n }\n\n if (isCapacitor) {\n await store.setLocalDirectory(e.target.checked ? {} : null);\n showToast(e.target.checked ? 'Capacitor folder sync activated' : 'Folder sync deactivated', 'success');\n render();\n return;\n }\n\n if (e.target.checked) {\n // Trigger directory picker\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory configured successfully', 'success');\n } catch (err) {\n console.error('Directory selection cancelled or failed:', err);\n toggle.checked = false;\n showToast('Folder selection cancelled', 'info');\n }\n } else {\n await store.setLocalDirectory(null);\n showToast('Direct directory synchronization disabled', 'info');\n }\n render();\n });\n\n tc.querySelector('#btn-pick-dir')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory updated successfully', 'success');\n render();\n } catch (err) {\n console.error(err);\n }\n });\n\n tc.querySelector('#btn-reauthorize-dir')?.addEventListener('click', async () => {\n const granted = await store.verifyDirPermission(true);\n if (granted) {\n showToast('Folder access re-authorized successfully', 'success');\n } else {\n showToast('Failed to acquire write permissions', 'error');\n }\n render();\n });\n\n tc.querySelector('#btn-disconnect-dir')?.addEventListener('click', async () => {\n await store.setLocalDirectory(null);\n showToast('Local folder disconnected', 'info');\n render();\n });\n\n tc.querySelector('#btn-force-sync')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n btn.disabled = true;\n showToast('Writing cache data to local directory...', 'info');\n try {\n const collections = Object.keys(store.cache);\n await Promise.all(collections.map(col => store.writeCollectionToFolder(col, store.cache[col])));\n showToast('Folder sync completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Folder sync failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n }\n });\n }\n\n render();\n }\n\n function renderAIAssistantTab(tc) {\n const s = store.getSettings();\n const ai = s.ai || {\n enabled: false,\n apiKey: '',\n endpoint: 'https://api.deepseek.com/chat/completions',\n model: 'deepseek-chat',\n systemPrompt: 'You are Relay, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n };\n\n function render() {\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg);\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>AI Assistant Configuration</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n \n <div class=\"form-group\">\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:pointer;\">\n <input type=\"checkbox\" id=\"ai-enabled\" ${ai.enabled ? 'checked' : ''} style=\"width:20px; height:20px; cursor:pointer;\" />\n <div>\n <span style=\"font-weight:600;\">Enable AI Assistant (DeepSeek)</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">Replaces the basic rule-based Relay co-pilot with a smart conversational LLM.</div>\n </div>\n </label>\n </div>\n\n <div id=\"ai-fields\" style=\"display: ${ai.enabled ? 'flex' : 'none'}; flex-direction:column; gap:16px; border-top:1px solid var(--border-color); padding-top:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">DeepSeek API Key</label>\n <input type=\"password\" class=\"form-input\" id=\"ai-apikey\" value=\"${ai.apiKey || ''}\" placeholder=\"sk-...\" />\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">Your API key is stored locally in your browser/sync directory and never sent to any third party other than the configured API endpoint.</div>\n </div>\n\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">API Endpoint</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-endpoint\" value=\"${ai.endpoint || 'https://api.deepseek.com/chat/completions'}\" placeholder=\"https://api.deepseek.com/chat/completions\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Model Name</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-model\" value=\"${ai.model || 'deepseek-chat'}\" placeholder=\"deepseek-chat\" />\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Base System Prompt</label>\n <textarea class=\"form-input\" id=\"ai-systemprompt\" rows=\"4\" style=\"font-family:inherit; resize:vertical;\">${ai.systemPrompt || ''}</textarea>\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.</div>\n </div>\n\n <div style=\"display:flex; gap:12px; align-items:center; margin-top:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-test-ai\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">bolt</span> Test Connection\n </button>\n <span id=\"test-status\" style=\"font-size:13px; font-weight:500;\"></span>\n </div>\n <div id=\"test-error-details\" style=\"display:none; margin-top:8px; font-size:12px; color:var(--color-danger); background:var(--color-danger-bg); border-left:3px solid var(--color-danger); padding:8px; border-radius:4px; line-height:1.4;\"></div>\n </div>\n\n <div style=\"border-top:1px solid var(--border-color); padding-top:16px; display:flex; justify-content:flex-end;\">\n <button class=\"btn btn-primary\" id=\"btn-save-ai\">Save AI Config</button>\n </div>\n\n </div>\n </div>\n\n </div>\n </div>\n `;\n\n // Toggle display of fields based on enabled checkbox\n const enabledCheckbox = tc.querySelector('#ai-enabled');\n const fieldsContainer = tc.querySelector('#ai-fields');\n enabledCheckbox.addEventListener('change', (e) => {\n fieldsContainer.style.display = e.target.checked ? 'flex' : 'none';\n });\n\n // Save handler\n tc.querySelector('#btn-save-ai').addEventListener('click', () => {\n const enabled = tc.querySelector('#ai-enabled').checked;\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const systemPrompt = tc.querySelector('#ai-systemprompt').value.trim();\n\n if (enabled && !apiKey) {\n showToast('API Key is required when enabling the AI assistant.', 'error');\n return;\n }\n\n const settings = store.getSettings();\n settings.ai = {\n enabled,\n apiKey,\n endpoint,\n model,\n systemPrompt\n };\n\n store.saveSettings(settings);\n showToast('AI settings saved successfully', 'success');\n });\n\n // Test handler\n tc.querySelector('#btn-test-ai').addEventListener('click', async () => {\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const statusEl = tc.querySelector('#test-status');\n const errEl = tc.querySelector('#test-error-details');\n\n const inElectron = !!(window.electronAPI && window.electronAPI.callDeepSeek);\n const hasEnvKey = !!(import.meta.env.VITE_DEEPSEEK_API_KEY);\n\n if (!apiKey && !inElectron && !hasEnvKey) {\n showToast('Enter an API key to test.', 'error');\n return;\n }\n\n statusEl.style.color = 'var(--text-secondary)';\n statusEl.textContent = 'Testing connection...';\n errEl.style.display = 'none';\n\n const startTime = Date.now();\n try {\n if (inElectron) {\n const data = await window.electronAPI.callDeepSeek({\n messages: [{ role: 'user', content: 'respond only with the word OK' }],\n endpoint,\n model,\n apiKey // Pass typed key if any\n });\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success (via Electron IPC)! Latency: ${latency}ms (Reply: \"${reply}\")`;\n } else {\n const res = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY}`\n },\n body: JSON.stringify({\n model: model || 'deepseek-chat',\n messages: [\n { role: 'user', content: 'respond only with the word OK' }\n ],\n max_tokens: 5\n })\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`HTTP ${res.status}: ${body}`);\n }\n\n const data = await res.json();\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success! Latency: ${latency}ms (Reply: \"${reply}\")`;\n }\n } catch (err) {\n console.error('AI test connection failed:', err);\n statusEl.style.color = 'var(--color-danger)';\n statusEl.textContent = 'Test failed';\n errEl.style.display = 'block';\n\n let errMsg = err.message;\n if (err.name === 'TypeError' && err.message.includes('Failed to fetch')) {\n errMsg = 'CORS Blocked or Offline. The browser blocked this cross-origin request. Please use a CORS proxy, an API gateway like OpenRouter, or check your internet connection.';\n }\n errEl.textContent = errMsg;\n }\n });\n }\n\n render();\n }\n\n\n\n"}, "fe527f8d33a62a24c2321cd7795ba686218da75d": {"t": 1785754184.6730409, "n": 250514, "h": "6202069410ae8db0", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData, seedData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\nimport { applyTheme, THEMES } from '../utils/theme.js';\nimport { storageGet, storageSet } from '../utils/tauriStore.js';\n\n// Compress uploaded images using Canvas to avoid huge Base64 data payloads\nfunction compressImage(dataUrl, maxWidth, maxHeight) {\n return new Promise((resolve) => {\n if (!dataUrl) {\n resolve(dataUrl);\n return;\n }\n // SVG vectors are already lightweight; do not compress them\n if (dataUrl.startsWith('data:image/svg+xml')) {\n resolve(dataUrl);\n return;\n }\n const img = new Image();\n img.onload = () => {\n let width = img.width;\n let height = img.height;\n\n if (width > height) {\n if (width > maxWidth) {\n height = Math.round((height * maxWidth) / width);\n width = maxWidth;\n }\n } else {\n if (height > maxHeight) {\n width = Math.round((width * maxHeight) / height);\n height = maxHeight;\n }\n }\n\n const canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n\n const ctx = canvas.getContext('2d');\n ctx.drawImage(img, 0, 0, width, height);\n\n const isPng = dataUrl.includes('image/png');\n const format = isPng ? 'image/png' : 'image/jpeg';\n const quality = isPng ? undefined : 0.85;\n\n resolve(canvas.toDataURL(format, quality));\n };\n img.onerror = () => {\n resolve(dataUrl);\n };\n img.src = dataUrl;\n });\n}\n\n// Helper to hash password using SHA-256 Web Crypto API\nasync function hashPassword(password) {\n const msgBuffer = new TextEncoder().encode(password);\n const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);\n const hashArray = Array.from(new Uint8Array(hashBuffer));\n return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');\n}\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n const localDeploymentType = settings.localDeploymentType || 'single_user';\n\n const isUsersDisabled = isLocalMode && localDeploymentType === 'single_user';\n const isPortalDisabled = isLocalMode;\n const isFolderSyncDisabled = !isLocalMode;\n\n if (isUsersDisabled && activeTab === 'users') {\n activeTab = 'company';\n }\n if (isPortalDisabled && activeTab === 'portal') {\n activeTab = 'company';\n }\n if (isFolderSyncDisabled && activeTab === 'folder_sync') {\n activeTab = 'company';\n }\n \n const isAIAssistantDisabled = isLocalMode;\n if (isAIAssistantDisabled && activeTab === 'ai_assistant') {\n activeTab = 'company';\n }\n\n const openMigrationModal = () => {\n const modalContent = document.createElement('div');\n modalContent.innerHTML = `\n <form id=\"convert-cloud-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Cloud Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a local/offline account. All data will be migrated to the secure cloud database.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"migrate-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"Company Name\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"migrate-admin-name\" required placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Phone Number</label>\n <input class=\"form-input\" id=\"migrate-admin-phone\" required placeholder=\"e.g. 0412 345 678\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Email Address (Username)</label>\n <input class=\"form-input\" type=\"email\" id=\"migrate-admin-email\" required placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Password</label>\n <input class=\"form-input\" type=\"password\" id=\"migrate-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"migration-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"migration-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-migrate-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-migrate-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" id=\"submit-icon\" style=\"font-size:18px;\">cloud_done</span>\n <span id=\"submit-text\">Register & Start Migration</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: 'Register & Migrate to Cloud',\n content: modalContent,\n size: 'modal-md'\n });\n\n modalContent.querySelector('#btn-migrate-cancel').addEventListener('click', close);\n\n const form = modalContent.querySelector('#convert-cloud-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n\n const errorEl = modalContent.querySelector('#migration-error');\n const errorTextEl = modalContent.querySelector('#migration-error-text');\n const submitBtn = modalContent.querySelector('#btn-migrate-submit');\n const cancelBtn = modalContent.querySelector('#btn-migrate-cancel');\n const submitText = modalContent.querySelector('#submit-text');\n const submitIcon = modalContent.querySelector('#submit-icon');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n submitText.textContent = 'Migrating to Cloud...';\n submitIcon.className = 'material-icons-outlined spinner';\n submitIcon.textContent = 'sync';\n submitIcon.style.animation = 'spin 1s linear infinite';\n\n const companyName = modalContent.querySelector('#migrate-company-name').value.trim();\n const adminName = modalContent.querySelector('#migrate-admin-name').value.trim();\n const adminPhone = modalContent.querySelector('#migrate-admin-phone').value.trim();\n const email = modalContent.querySelector('#migrate-admin-email').value.trim();\n const password = modalContent.querySelector('#migrate-admin-password').value;\n\n try {\n const settings = store.getSettings();\n settings.name = companyName;\n await store.saveSettings(settings);\n\n const { supabase } = await import('../utils/supabase.js');\n\n const { data: authData, error: authErr } = await supabase.auth.signUp({\n email,\n password,\n options: {\n data: {\n name: adminName,\n phone: adminPhone\n }\n }\n });\n if (authErr) throw authErr;\n\n if (!authData.user) {\n throw new Error('Verification required or signup was blocked. Check your email inbox.');\n }\n\n const { data: companyId, error: rpcError } = await supabase.rpc('create_company_and_admin', {\n user_id: authData.user.id,\n company_name: companyName,\n admin_name: adminName,\n admin_phone: adminPhone\n });\n if (rpcError) throw rpcError;\n\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n await store.migrateLocalToCloud(companyId, authData.user.id);\n\n if (activeAccountId) {\n const localAccountsKey = 'relay_accounts';\n let localAccounts = [];\n try {\n const stored = localStorage.getItem(localAccountsKey);\n if (stored) {\n localAccounts = JSON.parse(stored);\n }\n } catch (e) {\n console.error('Error reading local accounts:', e);\n }\n localAccounts = localAccounts.filter(a => a.id !== activeAccountId);\n localStorage.setItem(localAccountsKey, JSON.stringify(localAccounts));\n\n store.deleteLocalAccountData(activeAccountId);\n }\n\n const { data: profile, error: profileErr } = await supabase\n .from('profiles')\n .select('*')\n .eq('id', authData.user.id)\n .single();\n if (profileErr) throw profileErr;\n\n const user = {\n id: profile.id,\n companyId: profile.company_id,\n name: profile.name,\n role: profile.role,\n userTypeName: 'Admin',\n userTypeId: `${profile.company_id}_ut_admin`,\n color: profile.color || '#FF5C00',\n theme: 'light'\n };\n\n localStorage.setItem('currentUser', JSON.stringify(user));\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Migration completed successfully! Redirecting...', 'success');\n close();\n\n setTimeout(() => {\n window.location.hash = '#/';\n window.location.reload();\n }, 1500);\n\n } catch (err) {\n console.error('Migration failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during migration.';\n errorEl.style.display = 'flex';\n\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n submitText.textContent = 'Register & Start Migration';\n submitIcon.className = 'material-icons-outlined';\n submitIcon.textContent = 'cloud_done';\n submitIcon.style.animation = '';\n }\n });\n };\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''} ${isUsersDisabled ? 'disabled-local' : ''}\" data-tab=\"users\" ${isUsersDisabled ? 'data-tooltip=\"Requires Cloud Account or Local Server\" data-tooltip-pos=\"top\"' : ''}>Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''} ${isPortalDisabled ? 'disabled-local' : ''}\" data-tab=\"portal\" ${isPortalDisabled ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''}>Customer Portal</button>\n <button class=\"tab ${activeTab === 'invoices_quotes' ? 'active' : ''}\" data-tab=\"invoices_quotes\">Quotes &amp; Invoices</button>\n <button class=\"tab ${activeTab === 'folder_sync' ? 'active' : ''} ${isFolderSyncDisabled ? 'disabled-local' : ''}\" data-tab=\"folder_sync\" ${isFolderSyncDisabled ? 'data-tooltip=\"Requires Local Folder Storage\" data-tooltip-pos=\"top\"' : ''}>Folder Sync</button>\n <button class=\"tab ${activeTab === 'ai_assistant' ? 'active' : ''} ${isAIAssistantDisabled ? 'disabled-local' : ''}\" data-tab=\"ai_assistant\" ${isAIAssistantDisabled ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''}>AI Assistant</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n if (tab.classList.contains('disabled-local')) return;\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'folder_sync') {\n renderFolderSyncTab(tc);\n return;\n }\n\n if (activeTab === 'ai_assistant') {\n renderAIAssistantTab(tc);\n return;\n }\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n\n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Standard Logo (Large) using Canvas to ~600x300 max bounding box\n compressImage(re.target.result, 600, 300).then((compressed) => {\n pendingLogo = compressed;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Small/Shrunk Logo using Canvas to ~200x100 max bounding box\n compressImage(re.target.result, 200, 100).then((compressed) => {\n pendingLogoSmall = compressed;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n\n\n tc.querySelector('#btn-save-company').addEventListener('click', async () => {\n const saveBtn = tc.querySelector('#btn-save-company');\n const originalHtml = saveBtn.innerHTML;\n saveBtn.disabled = true;\n saveBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving Changes...';\n\n try {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n await store.saveSettings(settings);\n showToast('Company information saved successfully to database', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n render();\n } catch (err) {\n console.error('Error saving company profile settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n saveBtn.disabled = false;\n saveBtn.innerHTML = originalHtml;\n }\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n const pendingResets = store.getAll('passwordResetRequests') || [];\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div style=\"background:rgba(59, 130, 246, 0.1); border-left:4px solid #3b82f6; padding:12px 16px; margin-bottom:var(--space-md); border-radius:4px; font-size:13px; color:#f8fafc; display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:#3b82f6; font-size:18px;\">info</span>\n <span>Your company login code is <strong style=\"color:#60a5fa\">${companySlug}</strong>. Technicians log in using <strong style=\"color:#60a5fa\">username@${companySlug}</strong>.</span>\n </span>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Username</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n ${ut?.id !== 'ut_admin' ? `\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n ` : ''}\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\">\n <h4 style=\"margin:0\">Pending Password Reset Requests</h4>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Username/Email</th>\n <th>Requested On</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:24px\">No pending reset requests</td></tr>' : ''}\n ${pendingResets.filter(r => r.status === 'pending').map(r => {\n const tech = techs.find(t => t.id === r.technician_id) || {};\n const requestedAt = r.requested_at ? new Date(r.requested_at).toLocaleString() : 'Unknown';\n return `\n <tr>\n <td class=\"font-medium\">${tech.name || 'Unknown'}</td>\n <td class=\"text-secondary\">${r.employee_id || tech.username || ''}</td>\n <td class=\"text-tertiary\">${requestedAt}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-approve-reset\" data-id=\"${r.id}\" data-tech-id=\"${tech.id}\">Approve &amp; Reset</button>\n <button class=\"btn btn-sm btn-ghost text-danger btn-deny-reset\" data-id=\"${r.id}\">Deny</button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n ${isLocalMode ? '' : `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `}\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => {\n openUserModal();\n });\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-approve-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const techId = e.currentTarget.dataset.techId;\n const t = store.getById('technicians', techId);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <p>Approve password reset request for <strong>${t.name}</strong>? Please enter their new password below:</p>\n <div class=\"form-group\" style=\"margin-top:12px\">\n <label class=\"form-label\">New Password</label>\n <input type=\"password\" id=\"admin-reset-pwd-input\" class=\"form-input\" placeholder=\"Min. 6 characters\" minlength=\"6\" autofocus />\n </div>\n `;\n showModal({\n title: 'Approve Password Reset',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reset Password', className: 'btn-primary', onClick: c => {\n const pwdInput = document.getElementById('admin-reset-pwd-input');\n const newPwd = pwdInput ? pwdInput.value : '';\n if (!newPwd || newPwd.length < 6) {\n showToast('Password must be at least 6 characters.', 'error');\n return;\n }\n \n // Update technician password\n store.update('technicians', techId, { password: newPwd });\n \n // Approve the request\n store.update('passwordResetRequests', id, { status: 'approved', updated_at: new Date().toISOString() });\n \n showToast(`Password updated for ${t.name}`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-deny-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n store.update('passwordResetRequests', id, { status: 'denied', updated_at: new Date().toISOString() });\n showToast('Password reset request denied', 'info');\n renderContent();\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax Rates</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\" style=\"display:flex; flex-direction:column; gap:8px\">\n <label class=\"form-label\" style=\"display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:0\">\n <input type=\"checkbox\" id=\"tax-enabled\" style=\"width:16px; height:16px; margin:0\" ${settings.taxEnabled !== false ? 'checked' : ''} />\n Enable GST / Sales Tax\n </label>\n <div style=\"display:${settings.taxEnabled !== false ? 'flex' : 'none'}; align-items:center; gap:8px; margin-top:4px\" id=\"tax-rate-container\">\n <input class=\"form-input\" id=\"tax-rate\" type=\"number\" value=\"${settings.taxRate !== undefined ? settings.taxRate : 10}\" style=\"width:100px\" min=\"0\" max=\"100\" step=\"0.1\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // Toggle tax rate container visibility\n tc.querySelector('#tax-enabled')?.addEventListener('change', (e) => {\n const container = tc.querySelector('#tax-rate-container');\n if (container) {\n container.style.display = e.target.checked ? 'flex' : 'none';\n }\n });\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#save-tax-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const taxEnabled = tc.querySelector('#tax-enabled').checked;\n const taxRate = parseFloat(tc.querySelector('#tax-rate').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.taxEnabled = taxEnabled;\n settings.taxRate = taxRate;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n await store.saveSettings(settings);\n showToast('Financial and Rate settings saved successfully', 'success');\n renderContent();\n } catch (err) {\n console.error('Error saving tax and rate settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-portal-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n await store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving customer portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'system') {\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n const currentTheme = (currentUser && currentUser.id) ? (currentUser.theme || localStorage.getItem(`simpro_theme_${currentUser.id}`) || 'light') : 'light';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Left Column -->\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg)\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n </p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n ${currentUser.role === 'admin' ? `\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed a complete demonstration dataset\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed Demonstration Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n <button class=\"btn btn-danger\" id=\"btn-delete-company\" data-tooltip=\"Permanently delete the entire company profile and all data\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; background:var(--color-danger); border-color:var(--color-danger); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span> Delete Company Profile\n </button>\n\n ${isLocalMode ? `\n <div style=\"margin-top:16px; padding-top:12px; border-top:1px dashed rgba(220, 38, 38, 0.2)\">\n <h6 style=\"color:var(--color-warning); margin-bottom:8px; font-weight:600; display:flex; align-items:center; gap:4px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">warning</span> Irreversible Profile Conversions\n </h6>\n <p style=\"font-size:11px; color:var(--text-secondary); margin-bottom:12px; line-height:1.3;\">\n Convert your deployment type. These transitions are permanent and one-way.\n </p>\n ${localDeploymentType === 'single_user' ? `\n <button class=\"btn\" id=\"btn-convert-multiuser\" style=\"width:100%; justify-content:center; margin-bottom:12px; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">lan</span> Convert to Multi-User Local Network Sync\n </button>\n ` : ''}\n <button class=\"btn\" id=\"btn-convert-cloud-action\" style=\"width:100%; justify-content:center; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">cloud_upload</span> Convert to Cloud Sync\n </button>\n </div>\n ` : ''}\n </div>\n ` : '<div style=\"font-size:12.5px; color:var(--text-tertiary)\">Administrative actions are restricted to company administrators.</div>'}\n </div>\n </div>\n </div>\n\n <!-- Local Data Backup -->\n ${!isLocalMode && currentUser.role === 'admin' ? `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary)\">backup</span>\n <h4 style=\"margin:0;\">Local Data Backup</h4>\n </div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:12px;\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm); line-height:1.4; margin:0;\">\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n </p>\n <div id=\"backup-status-container\"></div>\n </div>\n </div>\n ` : ''}\n </div>\n\n <!-- Right Column -->\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">System Color Theme</label>\n <select class=\"form-select\" id=\"system-theme-select\" style=\"width:100%\">\n ${Object.entries(THEMES).map(([key, val]) => `\n <option value=\"${key}\" ${currentTheme === key ? 'selected' : ''}>${val.name}</option>\n `).join('')}\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Select a color profile and visual background effect for your user interface.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', async (e) => {\n const select = e.target;\n select.disabled = true;\n try {\n const settings = store.getSettings();\n settings.tooltipPreference = select.value;\n await store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved successfully', 'success');\n } catch (err) {\n console.error('Error saving tooltip preference:', err);\n showToast('Failed to save preferences: ' + (err.message || err), 'error');\n } finally {\n select.disabled = false;\n }\n });\n\n tc.querySelector('#system-theme-select')?.addEventListener('change', (e) => {\n applyTheme(e.target.value, true);\n showToast('System theme updated successfully', 'success');\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a complete demonstration dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load a complete, realistic demonstration dataset representing an active trade business (5 customers, 5 quotes/jobs per customer, 5 assets per customer, 5 materials, 5 users/timesheets, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for testing and walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed Demonstration Data\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Demo Data\",\n className: \"btn-primary\",\n onClick: async (close) => {\n close();\n showToast('Seeding demonstration data...', 'info');\n await seedMinimalData();\n showToast('Demonstration data seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-delete-company')?.addEventListener('click', () => {\n // Modal Warning 1 of 2\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">warning</span>\n <span>You are about to permanently delete the entire company profile for <strong>${escapeHTML(store.getSettings().name || '')}</strong> and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).</span>\n </div>\n <p style=\"font-size:12.5px; color:var(--text-secondary); margin-bottom:12px\">\n This action is final, irreversible, and cannot be undone under any circumstances.\n </p>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Do you want to proceed to password confirmation?</p>\n `;\n\n showModal({\n title: \"Delete Company Profile - Step 1 of 2\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (closeModal) => closeModal()\n },\n {\n label: \"Proceed to Confirm\",\n className: \"btn-danger\",\n onClick: (closeModal) => {\n closeModal();\n \n // Modal Warning 2 of 2\n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <form id=\"delete-company-confirm-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <p style=\"margin-bottom:8px; font-weight:600; color:var(--color-danger)\">FINAL CONFIRMATION (2 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">gavel</span>\n <span>To authorize the permanent destruction of this company, you must enter the Administrator password.</span>\n </div>\n\n <div class=\"form-group\" style=\"margin-top:12px;\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Password</label>\n <input class=\"form-input\" type=\"password\" id=\"delete-confirm-password\" required placeholder=\"Enter admin password to proceed\" />\n </div>\n\n <div id=\"delete-confirm-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"delete-confirm-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-delete-abort\">Abort Deletion</button>\n <button type=\"submit\" class=\"btn btn-danger\" id=\"btn-delete-confirm-submit\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span>\n <span>Permanently Delete Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close: close2 } = showModal({\n title: \"⚠️ Confirm Company Deletion - Step 2 of 2\",\n content: content2,\n size: \"modal-md\"\n });\n\n content2.querySelector('#btn-delete-abort').addEventListener('click', close2);\n\n const form2 = content2.querySelector('#delete-company-confirm-form');\n form2.addEventListener('submit', async (ev) => {\n ev.preventDefault();\n\n const errorEl = content2.querySelector('#delete-confirm-error');\n const errorTextEl = content2.querySelector('#delete-confirm-error-text');\n const submitBtn = content2.querySelector('#btn-delete-confirm-submit');\n const abortBtn = content2.querySelector('#btn-delete-abort');\n const passwordInput = content2.querySelector('#delete-confirm-password').value;\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n abortBtn.disabled = true;\n const origSubmitText = submitBtn.innerHTML;\n submitBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Deleting...';\n\n try {\n let passwordVerified = false;\n\n if (isLocalMode) {\n if (localDeploymentType === 'single_user') {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acct = storedAccounts.find(a => a.id === activeAccountId);\n if (acct && acct.hasPassword) {\n const hashedInput = await hashPassword(passwordInput);\n if (hashedInput === acct.passwordHash) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n // No password set\n passwordVerified = true;\n }\n } else {\n // local_multiuser\n const techs = store.getAll('technicians') || [];\n const currentTech = techs.find(t => t.id === currentUser.id);\n const expectedPassword = currentTech ? currentTech.password : '123456';\n if (passwordInput === expectedPassword) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n }\n } else {\n // Cloud mode\n const { supabase } = await import('../utils/supabase.js');\n const { data: { user } } = await supabase.auth.getUser();\n if (user && user.email) {\n const { error } = await supabase.auth.signInWithPassword({\n email: user.email,\n password: passwordInput\n });\n if (!error) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n throw new Error('Could not retrieve logged-in cloud user credentials.');\n }\n }\n\n if (passwordVerified) {\n // 1. If cloud mode, delete the company row in Supabase\n if (!isLocalMode) {\n const { supabase } = await import('../utils/supabase.js');\n const { error: deleteErr } = await supabase.from('companies').delete().eq('id', store.companyId);\n if (deleteErr) {\n throw new Error('Failed to delete cloud company: ' + deleteErr.message);\n }\n }\n\n // 2. Trigger store.clearAll()\n store.clearAll();\n\n // 3. Clear local account launcher keys if local mode\n if (isLocalMode) {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const updatedAccounts = storedAccounts.filter(a => a.id !== activeAccountId);\n await storageSet('relay_accounts', updatedAccounts);\n }\n }\n\n // 4. Remove session user & active account\n localStorage.removeItem('currentUser');\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Company profile deleted successfully.', 'success');\n close2();\n\n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n } catch (err) {\n console.error('Company deletion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during verification.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n abortBtn.disabled = false;\n submitBtn.innerHTML = origSubmitText;\n }\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-convert-multiuser')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n\n const techs = store.getAll('technicians') || [];\n const companyId = store.companyId || '';\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const existingAdmin = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n const currentTechName = existingAdmin ? existingAdmin.name : (currentUser.name && currentUser.name !== 'Local Admin' ? currentUser.name : '');\n const currentTechUsernameOrEmail = existingAdmin ? (existingAdmin.email || existingAdmin.username || '') : '';\n\n content.innerHTML = `\n <form id=\"convert-multiuser-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Multi-User Local Network Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a single-user Local Admin profile.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"multiuser-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"e.g. Apex Power Services\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"multiuser-admin-name\" required value=\"${escapeHTML(currentTechName)}\" placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Email Address (Required)</label>\n <input class=\"form-input\" type=\"email\" id=\"multiuser-admin-email\" required value=\"${escapeHTML(currentTechUsernameOrEmail)}\" placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Admin Password</label>\n <input class=\"form-input\" type=\"password\" id=\"multiuser-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"multiuser-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"multiuser-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-multiuser-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-multiuser-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">lan</span>\n <span>Convert Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: \"Convert to Multi-User Local Sync\",\n content: content,\n size: \"modal-md\"\n });\n\n content.querySelector('#btn-multiuser-cancel').addEventListener('click', close);\n\n const form = content.querySelector('#convert-multiuser-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n const errorEl = content.querySelector('#multiuser-error');\n const errorTextEl = content.querySelector('#multiuser-error-text');\n const submitBtn = content.querySelector('#btn-multiuser-submit');\n const cancelBtn = content.querySelector('#btn-multiuser-cancel');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n\n const businessName = content.querySelector('#multiuser-company-name').value.trim();\n const adminName = content.querySelector('#multiuser-admin-name').value.trim();\n const adminEmail = content.querySelector('#multiuser-admin-email').value.trim();\n const adminPassword = content.querySelector('#multiuser-admin-password').value;\n\n try {\n if (!businessName || !adminName || !adminEmail || !adminPassword) {\n throw new Error('All fields are required.');\n }\n if (adminPassword.length < 6) {\n throw new Error('Password must be at least 6 characters.');\n }\n\n // 1. Update settings\n const settings = store.getSettings();\n settings.name = businessName;\n settings.localDeploymentType = 'multi_user';\n await store.saveSettings(settings);\n\n // 2. Update launcher accounts businessName\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acctIndex = storedAccounts.findIndex(a => a.id === activeAccountId);\n if (acctIndex !== -1) {\n storedAccounts[acctIndex].businessName = businessName;\n await storageSet('relay_accounts', storedAccounts);\n }\n }\n\n // 3. Create or update admin user in technicians\n const companyId = store.companyId;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const techs = store.getAll('technicians') || [];\n let adminTech = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n if (!adminTech) {\n adminTech = {\n id: `${companyId}_tech_admin_created`,\n role: 'Admin',\n color: '#FF5C00',\n userTypeId: adminTypeId,\n payRate: 100.00,\n phone: ''\n };\n techs.push(adminTech);\n }\n\n adminTech.name = adminName;\n adminTech.username = adminEmail.split('@')[0];\n adminTech.email = adminEmail;\n adminTech.password = adminPassword;\n\n store.save('technicians', techs);\n\n // 4. Update currentUser session in localStorage\n const localUser = {\n id: adminTech.id,\n companyId: companyId,\n name: adminTech.name,\n role: 'admin',\n userTypeName: 'Admin',\n userTypeId: adminTech.userTypeId,\n color: adminTech.color || '#FF5C00',\n theme: 'light'\n };\n localStorage.setItem('currentUser', JSON.stringify(localUser));\n\n showToast('Converted to Multi-User Local Network Sync. Reloading...', 'success');\n close();\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n setTimeout(() => window.location.reload(), 1200);\n } catch (err) {\n console.error('Conversion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during conversion.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n }\n });\n });\n\n tc.querySelector('#btn-convert-cloud-action')?.addEventListener('click', () => {\n openMigrationModal();\n });\n\n const renderBackupStatus = () => {\n const bsc = tc.querySelector('#backup-status-container');\n if (!bsc) return;\n\n const hasHandle = !!store.backupDirHandle;\n const isPermissionGranted = store.backupDirPermissionGranted;\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const formattedLastBackup = lastBackup ? new Date(lastBackup).toLocaleString() : 'Never';\n const isSupported = typeof window !== 'undefined' && window.showDirectoryPicker;\n\n if (!isSupported) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-danger); line-height:1.4;\">\n <strong style=\"display:block; margin-bottom:4px;\">Browser Local Folder Access Unsupported</strong>\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n </div>\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:12px; border-radius:6px; margin-bottom:12px; color:var(--text-secondary); display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary);\">folder_off</span>\n <div style=\"font-size:12.5px;\">No backup folder configured.</div>\n </div>\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">folder</span> Choose Backup Folder...\n </button>\n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-warning); line-height:1.4; margin-bottom:12px;\">\n <strong>Permission Required</strong><br/>\n Access permission to <strong>${escapeHTML(store.backupDirHandle.name)}</strong> has expired. Please re-authorize.\n </div>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-warning\" id=\"btn-backup-auth\" style=\"flex:1; justify-content:center;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize & Backup\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n `;\n } else {\n bsc.innerHTML = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-success); line-height:1.4; margin-bottom:12px;\">\n <strong>Backup Configured</strong><br/>\n Folder: <strong>${escapeHTML(store.backupDirHandle.name)}</strong><br/>\n Last Backup: <strong>${formattedLastBackup}</strong>\n </div>\n <div style=\"display:flex; flex-direction:column; gap:8px;\">\n <button class=\"btn btn-primary\" id=\"btn-backup-now\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">backup</span> Backup Now\n </button>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"flex:1; justify-content:center; border:1px solid var(--border-color);\">\n <span class=\"material-icons-outlined\">folder</span> Change Folder...\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n </div>\n `;\n }\n\n // Attach event listeners for backup buttons inside container\n bsc.querySelector('#btn-backup-pick')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setBackupDirectory(handle);\n showToast('Backup directory selected successfully.', 'success');\n showToast('Running initial backup...', 'info');\n await store.backupToFolder(handle);\n showToast('Backup completed successfully!', 'success');\n renderBackupStatus();\n } catch (err) {\n console.error('Failed to select backup directory:', err);\n if (err.name !== 'AbortError') {\n showToast('Failed to configure backup: ' + err.message, 'error');\n }\n }\n });\n\n bsc.querySelector('#btn-backup-auth')?.addEventListener('click', async () => {\n const granted = await store.verifyBackupDirPermission(true);\n if (granted) {\n showToast('Permission re-authorized. Backing up...', 'info');\n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n showToast('Backup failed: ' + err.message, 'error');\n }\n } else {\n showToast('Failed to acquire write permission.', 'error');\n }\n renderBackupStatus();\n });\n\n bsc.querySelector('#btn-backup-now')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Backing up...';\n \n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Backup failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n renderBackupStatus();\n }\n });\n\n bsc.querySelector('#btn-backup-disconnect')?.addEventListener('click', async () => {\n await store.setBackupDirectory(null);\n showToast('Backup folder disconnected.', 'info');\n renderBackupStatus();\n });\n };\n\n if (!isLocalMode && currentUser.role === 'admin') {\n renderBackupStatus();\n }\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', username: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Username</label>\n <input class=\"form-input\" id=\"u-username\" value=\"${t.username || (t.email ? t.email.split('@')[0] : '')}\" ${editId ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''} placeholder=\"e.g. joshua\" />\n ${!editId ? `\n <div style=\"font-size: 11px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500;\">\n Company login code is: <strong style=\"color: var(--color-primary)\">${companySlug}</strong>. User will log in with <strong style=\"color: var(--color-primary)\">username@${companySlug}</strong>\n </div>\n ` : ''}\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">${editId ? 'Reset Password (leave blank to keep current)' : 'Temporary Password (assigned for worker login)'}</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"${editId ? '••••••••' : 'Min. 6 characters'}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\" ${(t.userTypeId === 'ut_admin' || (t.userTypeId && t.userTypeId.endsWith('_ut_admin'))) ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''}>\n <option value=\"\">-- Select --</option>\n ${userTypes\n .filter(ut => (!ut.id.endsWith('_ut_admin') && ut.id !== 'ut_admin') || t.userTypeId === ut.id)\n .map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value.trim();\n const username = document.getElementById('u-username').value.trim();\n const role = document.getElementById('u-role').value.trim();\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = document.getElementById('u-password')?.value || '';\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!username) { showToast('Username required', 'error'); return; }\n if (username.includes('@')) { showToast('Username cannot contain @ symbol', 'error'); return; }\n \n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (password && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n const updates = { name, username, role, userTypeId, color, payRate };\n if (password) {\n updates.password = password;\n }\n\n if (editId) {\n await store.update('technicians', editId, updates);\n } else {\n await store.create('technicians', updates);\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = async () => {\n const btn = tc.querySelector('#btn-save-materials');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n await store.saveSettings(updatedSettings);\n showToast('Material settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving material settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-00023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-00129',\n originalQuoteNumber: 'Q-00412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' },\n ballet: { name: 'Ballet Pointe', preset: 'ballet', accentColor: '#e58799', headerBg: '#5c2c36', accentTint: '#fff5f6', fontFamily: 'serif' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-company-name\" style=\"width:16px; height:16px\" ${dt.hideCompanyName ? 'checked' : ''} />\n <label for=\"theme-hide-company-name\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide company name text (use logo only)</label>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Variation</label>\n <select class=\"form-select\" id=\"theme-logo-source\" style=\"width:100%\">\n <option value=\"large\" ${dt.logoSource === 'large' ? 'selected' : ''}>Standard Logo (Large)</option>\n <option value=\"small\" ${dt.logoSource === 'small' ? 'selected' : ''}>Small / Shrunk Logo (Small)</option>\n </select>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:8px 0\"></div>\n <div class=\"form-group ${isLocalMode ? 'disabled-local' : ''}\" ${isLocalMode ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''} style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-brand-logo\" style=\"width:16px; height:16px\" ${dt.hideBrandLogo ? 'checked' : ''} ${isLocalMode ? 'disabled' : ''} />\n <label for=\"theme-hide-brand-logo\" class=\"form-label\" style=\"margin:0; cursor:${isLocalMode ? 'not-allowed' : 'pointer'}\">Remove Relay-dispatch brand logo from document footers</label>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Hide Company Name toggle\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) {\n hideCompanyNameChk.addEventListener('change', () => {\n dt.hideCompanyName = hideCompanyNameChk.checked;\n updatePreview();\n });\n }\n\n // Logo Variation toggle\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) {\n logoSourceSelect.addEventListener('change', (e) => {\n dt.logoSource = e.target.value;\n updatePreview();\n });\n }\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Hide Brand Logo toggle\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) {\n hideBrandLogoChk.addEventListener('change', () => {\n dt.hideBrandLogo = hideBrandLogoChk.checked;\n updatePreview();\n });\n }\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-theme-save');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n await store.saveSettings(freshSettings);\n showToast('Document customization settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving theme settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n hideCompanyName: false,\n hideBrandLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) hideCompanyNameChk.checked = !!dt.hideCompanyName;\n\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) logoSourceSelect.value = dt.logoSource || 'large';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) hideBrandLogoChk.checked = !!dt.hideBrandLogo;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n const logoUrl = dt.logoSource === 'small' ? (settings.logoSmall || settings.logo) : (settings.logo || settings.logoSmall);\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.src = logoUrl || '';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // Company Name visibility\n const companyNameEl = frameDoc.querySelector('.pdf-company-name');\n if (companyNameEl) {\n companyNameEl.style.display = dt.hideCompanyName ? 'none' : 'block';\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n\n const footerBrandingEl = frameDoc.querySelector('.pdf-footer-branding');\n if (footerBrandingEl) {\n const hideBrandLogo = isLocalMode ? false : !!dt.hideBrandLogo;\n footerBrandingEl.style.display = hideBrandLogo ? 'none' : 'flex';\n }\n }\n\n renderMarkup();\n }\n\n function renderFolderSyncTab(tc) {\n const isSupported = typeof window !== 'undefined' && (\n (window.indexedDB && window.showDirectoryPicker) ||\n (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem)\n );\n\n const isCapacitor = typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem;\n\n const activeAccountId = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n const isLocalCompany = !!activeAccountId;\n\n function render() {\n console.log('[DEBUG Settings] render: isLocalCompany =', isLocalCompany, 'store.dirHandle =', store.dirHandle, 'permissionGranted =', store.folderSyncPermissionGranted);\n const isEnabled = isLocalCompany ? true : store.folderSyncEnabled;\n const isPermissionGranted = store.folderSyncPermissionGranted;\n const hasHandle = !!store.dirHandle;\n\n let statusHtml = '';\n if (!isSupported) {\n statusHtml = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:16px; border-radius:4px; color:var(--color-danger); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:8px; font-weight:600; margin-bottom:4px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Browser Directory Access Unsupported</span>\n </div>\n <p style=\"font-size:var(--font-size-sm); margin:0; line-height:1.4;\">\n Your current browser does not support local folder access. To enable direct folder synchronization, please run this app in a Chromium-based browser (Chrome, Edge, Opera, or as a compiled native app). High-capacity IndexedDB storage remains active.\n </p>\n </div>\n `;\n } else if (!isEnabled) {\n statusHtml = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:16px; border-radius:6px; color:var(--text-secondary); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px; color:var(--text-tertiary);\">folder_off</span>\n <div>\n <div style=\"font-weight:600; color:var(--text-primary); margin-bottom:2px;\">Folder Synchronization Inactive</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4;\">All data is currently stored in your browser's private IndexedDB database sandbox.</p>\n </div>\n </div>\n `;\n } else if (isCapacitor) {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">cloud_done</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Capacitor Direct Folder Sync Active</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Data is synchronizing directly to the application's native <strong>Documents/RelayDispatchData</strong> directory on your iPad/device.\n </p>\n </div>\n </div>\n `;\n } else if (!hasHandle) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">warning</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">No Directory Selected</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">Please select a directory folder on your computer to begin syncing.</p>\n </div>\n </div>\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:12px; margin-bottom:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">lock</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Access Permission Suspended</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n The browser requires re-authorization to read/write files in <strong>${escapeHTML(store.dirHandle.name)}</strong>.\n </p>\n </div>\n </div>\n <button class=\"btn btn-warning\" id=\"btn-reauthorize-dir\" style=\"margin-left:44px;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize Access\n </button>\n </div>\n `;\n } else {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">check_circle</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Folder Sync Active & Synchronized</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Active Folder: <strong>${escapeHTML(store.dirHandle.name)}</strong>. All data edits are writing dynamically.\n </p>\n </div>\n </div>\n `;\n }\n\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <!-- Folder Configuration -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Direct Directory Sync</h4></div>\n <div class=\"card-body\">\n ${statusHtml}\n\n <div class=\"form-group\" style=\"margin-bottom:var(--space-lg);\">\n <label class=\"form-label\" style=\"font-weight:600; margin-bottom:8px;\">Synchronization Toggle</label>\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\">\n <input type=\"checkbox\" id=\"toggle-folder-sync\" ${isEnabled ? 'checked' : ''} ${(!isSupported || isLocalCompany) ? 'disabled' : ''} style=\"width:20px; height:20px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\" />\n <div>\n <span style=\"font-weight:500;\">Enable Direct Local Folder Storage</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">\n ${isLocalCompany ? 'Mandatory for offline/local company profile storage.' : 'Saves all data records and attachments straight to your machine.'}\n </div>\n </div>\n </label>\n </div>\n\n ${isEnabled && isSupported && !isCapacitor ? `\n <div style=\"display:flex; gap:12px; flex-wrap:wrap; border-top:1px solid var(--border-color); padding-top:var(--space-lg);\">\n <button class=\"btn btn-secondary\" id=\"btn-pick-dir\">\n <span class=\"material-icons-outlined\">folder</span> Choose Sync Folder...\n </button>\n ${hasHandle && isPermissionGranted ? `\n <button class=\"btn btn-secondary\" id=\"btn-force-sync\" data-tooltip=\"Overwrite folder JSONs with current memory cache\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">sync</span> Sync Now\n </button>\n ${!isLocalCompany ? `\n <button class=\"btn btn-danger\" id=\"btn-disconnect-dir\">\n <span class=\"material-icons-outlined\">link_off</span> Disconnect Folder\n </button>\n ` : ''}\n ` : ''}\n </div>\n ` : ''}\n </div>\n </div>\n\n <!-- Instructions Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>How Self-Hosting Works</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p>\n By linking a local folder, you establish a <strong>serverless self-hosted data hub</strong>.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">storage</span>\n <span><strong>Readable Data:</strong> Your jobs, quotes, and company configurations are saved in <code>data/*.json</code> files.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">photo_library</span>\n <span><strong>Physical Attachments:</strong> Uploaded photos and PDF catalogs are saved as standard image/PDF files under the <code>documents/</code> subfolder.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">sync_alt</span>\n <span><strong>Cloud Backups:</strong> Select a folder synced to <strong>OneDrive, Google Drive, or Dropbox</strong> to automatically back up and sync your data to the cloud.</span>\n </div>\n </div>\n </div>\n </div>\n `;\n\n // Event Listeners\n const toggle = tc.querySelector('#toggle-folder-sync');\n toggle?.addEventListener('change', async (e) => {\n if (isLocalCompany) {\n e.preventDefault();\n toggle.checked = true;\n return;\n }\n\n if (isCapacitor) {\n await store.setLocalDirectory(e.target.checked ? {} : null);\n showToast(e.target.checked ? 'Capacitor folder sync activated' : 'Folder sync deactivated', 'success');\n render();\n return;\n }\n\n if (e.target.checked) {\n // Trigger directory picker\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory configured successfully', 'success');\n } catch (err) {\n console.error('Directory selection cancelled or failed:', err);\n toggle.checked = false;\n showToast('Folder selection cancelled', 'info');\n }\n } else {\n await store.setLocalDirectory(null);\n showToast('Direct directory synchronization disabled', 'info');\n }\n render();\n });\n\n tc.querySelector('#btn-pick-dir')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory updated successfully', 'success');\n render();\n } catch (err) {\n console.error(err);\n }\n });\n\n tc.querySelector('#btn-reauthorize-dir')?.addEventListener('click', async () => {\n const granted = await store.verifyDirPermission(true);\n if (granted) {\n showToast('Folder access re-authorized successfully', 'success');\n } else {\n showToast('Failed to acquire write permissions', 'error');\n }\n render();\n });\n\n tc.querySelector('#btn-disconnect-dir')?.addEventListener('click', async () => {\n await store.setLocalDirectory(null);\n showToast('Local folder disconnected', 'info');\n render();\n });\n\n tc.querySelector('#btn-force-sync')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n btn.disabled = true;\n showToast('Writing cache data to local directory...', 'info');\n try {\n const collections = Object.keys(store.cache);\n await Promise.all(collections.map(col => store.writeCollectionToFolder(col, store.cache[col])));\n showToast('Folder sync completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Folder sync failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n }\n });\n }\n\n render();\n }\n\n function renderAIAssistantTab(tc) {\n const s = store.getSettings();\n const ai = s.ai || {\n enabled: false,\n apiKey: '',\n endpoint: 'https://api.deepseek.com/chat/completions',\n model: 'deepseek-chat',\n systemPrompt: 'You are Relay, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n };\n\n function render() {\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg);\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>AI Assistant Configuration</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n \n <div class=\"form-group\">\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:pointer;\">\n <input type=\"checkbox\" id=\"ai-enabled\" ${ai.enabled ? 'checked' : ''} style=\"width:20px; height:20px; cursor:pointer;\" />\n <div>\n <span style=\"font-weight:600;\">Enable AI Assistant (DeepSeek)</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">Replaces the basic rule-based Relay co-pilot with a smart conversational LLM.</div>\n </div>\n </label>\n </div>\n\n <div id=\"ai-fields\" style=\"display: ${ai.enabled ? 'flex' : 'none'}; flex-direction:column; gap:16px; border-top:1px solid var(--border-color); padding-top:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">DeepSeek API Key</label>\n <input type=\"password\" class=\"form-input\" id=\"ai-apikey\" value=\"${ai.apiKey || ''}\" placeholder=\"sk-...\" />\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">Your API key is stored locally in your browser/sync directory and never sent to any third party other than the configured API endpoint.</div>\n </div>\n\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">API Endpoint</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-endpoint\" value=\"${ai.endpoint || 'https://api.deepseek.com/chat/completions'}\" placeholder=\"https://api.deepseek.com/chat/completions\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Model Name</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-model\" value=\"${ai.model || 'deepseek-chat'}\" placeholder=\"deepseek-chat\" />\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Base System Prompt</label>\n <textarea class=\"form-input\" id=\"ai-systemprompt\" rows=\"4\" style=\"font-family:inherit; resize:vertical;\">${ai.systemPrompt || ''}</textarea>\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.</div>\n </div>\n\n <div style=\"display:flex; gap:12px; align-items:center; margin-top:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-test-ai\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">bolt</span> Test Connection\n </button>\n <span id=\"test-status\" style=\"font-size:13px; font-weight:500;\"></span>\n </div>\n <div id=\"test-error-details\" style=\"display:none; margin-top:8px; font-size:12px; color:var(--color-danger); background:var(--color-danger-bg); border-left:3px solid var(--color-danger); padding:8px; border-radius:4px; line-height:1.4;\"></div>\n </div>\n\n <div style=\"border-top:1px solid var(--border-color); padding-top:16px; display:flex; justify-content:flex-end;\">\n <button class=\"btn btn-primary\" id=\"btn-save-ai\">Save AI Config</button>\n </div>\n\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Your Personal Co-Pilot Factsheet (User Memory)</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:12px;\">\n <div class=\"text-tertiary\" style=\"font-size:12px; line-height:1.4;\">\n This factsheet stores preferences, custom habits, and patterns specific to your user profile (e.g. how you write notes, your default technicians, or the types of jobs you handle). Relay loads this context dynamically. You can edit it manually below, or tell Relay to remember facts in the chat.\n </div>\n <div class=\"form-group\" style=\"margin-top:4px;\">\n <textarea class=\"form-input\" id=\"ai-factsheet\" rows=\"6\" style=\"font-family:inherit; resize:vertical; font-size:13px;\" placeholder=\"Write down your preferences here (e.g. 'I prefer scheduling HVAC jobs to John Doe on Mondays' or 'I like writing job notes in bullet points').\"></textarea>\n </div>\n <div style=\"border-top:1px solid var(--border-color); padding-top:12px; display:flex; justify-content:flex-end;\">\n <button class=\"btn btn-secondary\" id=\"btn-save-factsheet\">Save Factsheet</button>\n </div>\n </div>\n </div>\n </div>\n\n <!-- Helper instructions card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>Cheapest Smart AI</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p>\n <strong>DeepSeek</strong> is one of the most cost-efficient and high-performance language models currently available, making it perfect for lightweight co-pilot tasks.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">key</span>\n <span><strong>API Key:</strong> Get your key from <a href=\"https://platform.deepseek.com/\" target=\"_blank\" style=\"color:var(--color-primary); text-decoration:underline;\">platform.deepseek.com</a>. New accounts typically receive free trial credits.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">security</span>\n <span><strong>CORS Restrictions:</strong> Due to browser security, direct API calls to DeepSeek may be blocked. To bypass this:\n <ul style=\"margin:4px 0 0 16px; padding:0; list-style:disc; font-size:12px;\">\n <li>Route via a CORS proxy (e.g. <code>https://cors-anywhere.herokuapp.com/</code> prefix).</li>\n <li>Connect via an aggregator like <strong>OpenRouter</strong> (API Endpoint: <code>https://openrouter.ai/api/v1/chat/completions</code>).</li>\n <li>Use a local LLM runner like <strong>Ollama</strong> (API Endpoint: <code>http://localhost:11434/v1/chat/completions</code>).</li>\n </ul>\n </span>\n </div>\n </div>\n </div>\n </div>\n `;\n\n // Toggle display of fields based on enabled checkbox\n const enabledCheckbox = tc.querySelector('#ai-enabled');\n const fieldsContainer = tc.querySelector('#ai-fields');\n enabledCheckbox.addEventListener('change', (e) => {\n fieldsContainer.style.display = e.target.checked ? 'flex' : 'none';\n });\n\n // Load factsheet\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n const userId = currentUser ? currentUser.id : 'default';\n const factsheetKey = `relay_factsheet_${userId}`;\n const factsheetTextarea = tc.querySelector('#ai-factsheet');\n if (factsheetTextarea) {\n factsheetTextarea.value = localStorage.getItem(factsheetKey) || '';\n }\n\n tc.querySelector('#btn-save-factsheet').addEventListener('click', () => {\n const val = factsheetTextarea.value.trim();\n localStorage.setItem(factsheetKey, val);\n showToast('Personal factsheet saved successfully', 'success');\n });\n\n // Save handler\n tc.querySelector('#btn-save-ai').addEventListener('click', () => {\n const enabled = tc.querySelector('#ai-enabled').checked;\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const systemPrompt = tc.querySelector('#ai-systemprompt').value.trim();\n\n if (enabled && !apiKey) {\n showToast('API Key is required when enabling the AI assistant.', 'error');\n return;\n }\n\n const settings = store.getSettings();\n settings.ai = {\n enabled,\n apiKey,\n endpoint,\n model,\n systemPrompt\n };\n\n store.saveSettings(settings);\n showToast('AI settings saved successfully', 'success');\n });\n\n // Test handler\n tc.querySelector('#btn-test-ai').addEventListener('click', async () => {\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const statusEl = tc.querySelector('#test-status');\n const errEl = tc.querySelector('#test-error-details');\n\n const inElectron = !!(window.electronAPI && window.electronAPI.callDeepSeek);\n const hasEnvKey = !!(import.meta.env.VITE_DEEPSEEK_API_KEY);\n\n if (!apiKey && !inElectron && !hasEnvKey) {\n showToast('Enter an API key to test.', 'error');\n return;\n }\n\n statusEl.style.color = 'var(--text-secondary)';\n statusEl.textContent = 'Testing connection...';\n errEl.style.display = 'none';\n\n const startTime = Date.now();\n try {\n if (inElectron) {\n const data = await window.electronAPI.callDeepSeek({\n messages: [{ role: 'user', content: 'respond only with the word OK' }],\n endpoint,\n model,\n apiKey // Pass typed key if any\n });\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success (via Electron IPC)! Latency: ${latency}ms (Reply: \"${reply}\")`;\n } else {\n const res = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY}`\n },\n body: JSON.stringify({\n model: model || 'deepseek-chat',\n messages: [\n { role: 'user', content: 'respond only with the word OK' }\n ],\n max_tokens: 5\n })\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`HTTP ${res.status}: ${body}`);\n }\n\n const data = await res.json();\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success! Latency: ${latency}ms (Reply: \"${reply}\")`;\n }\n } catch (err) {\n console.error('AI test connection failed:', err);\n statusEl.style.color = 'var(--color-danger)';\n statusEl.textContent = 'Test failed';\n errEl.style.display = 'block';\n\n let errMsg = err.message;\n if (err.name === 'TypeError' && err.message.includes('Failed to fetch')) {\n errMsg = 'CORS Blocked or Offline. The browser blocked this cross-origin request. Please use a CORS proxy, an API gateway like OpenRouter, or check your internet connection.';\n }\n errEl.textContent = errMsg;\n }\n });\n }\n\n render();\n }\n\n\n\n"}, "1f07f8ee860408b426818f764ac4c1887ad010d4": {"t": 1785754187.3993537, "n": 248314, "h": "238bc87b849b533c", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData, seedData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\nimport { applyTheme, THEMES } from '../utils/theme.js';\nimport { storageGet, storageSet } from '../utils/tauriStore.js';\n\n// Compress uploaded images using Canvas to avoid huge Base64 data payloads\nfunction compressImage(dataUrl, maxWidth, maxHeight) {\n return new Promise((resolve) => {\n if (!dataUrl) {\n resolve(dataUrl);\n return;\n }\n // SVG vectors are already lightweight; do not compress them\n if (dataUrl.startsWith('data:image/svg+xml')) {\n resolve(dataUrl);\n return;\n }\n const img = new Image();\n img.onload = () => {\n let width = img.width;\n let height = img.height;\n\n if (width > height) {\n if (width > maxWidth) {\n height = Math.round((height * maxWidth) / width);\n width = maxWidth;\n }\n } else {\n if (height > maxHeight) {\n width = Math.round((width * maxHeight) / height);\n height = maxHeight;\n }\n }\n\n const canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n\n const ctx = canvas.getContext('2d');\n ctx.drawImage(img, 0, 0, width, height);\n\n const isPng = dataUrl.includes('image/png');\n const format = isPng ? 'image/png' : 'image/jpeg';\n const quality = isPng ? undefined : 0.85;\n\n resolve(canvas.toDataURL(format, quality));\n };\n img.onerror = () => {\n resolve(dataUrl);\n };\n img.src = dataUrl;\n });\n}\n\n// Helper to hash password using SHA-256 Web Crypto API\nasync function hashPassword(password) {\n const msgBuffer = new TextEncoder().encode(password);\n const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);\n const hashArray = Array.from(new Uint8Array(hashBuffer));\n return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');\n}\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n const localDeploymentType = settings.localDeploymentType || 'single_user';\n\n const isUsersDisabled = isLocalMode && localDeploymentType === 'single_user';\n const isPortalDisabled = isLocalMode;\n const isFolderSyncDisabled = !isLocalMode;\n\n if (isUsersDisabled && activeTab === 'users') {\n activeTab = 'company';\n }\n if (isPortalDisabled && activeTab === 'portal') {\n activeTab = 'company';\n }\n if (isFolderSyncDisabled && activeTab === 'folder_sync') {\n activeTab = 'company';\n }\n \n const isAIAssistantDisabled = isLocalMode;\n if (isAIAssistantDisabled && activeTab === 'ai_assistant') {\n activeTab = 'company';\n }\n\n const openMigrationModal = () => {\n const modalContent = document.createElement('div');\n modalContent.innerHTML = `\n <form id=\"convert-cloud-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Cloud Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a local/offline account. All data will be migrated to the secure cloud database.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"migrate-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"Company Name\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"migrate-admin-name\" required placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Phone Number</label>\n <input class=\"form-input\" id=\"migrate-admin-phone\" required placeholder=\"e.g. 0412 345 678\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Email Address (Username)</label>\n <input class=\"form-input\" type=\"email\" id=\"migrate-admin-email\" required placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Password</label>\n <input class=\"form-input\" type=\"password\" id=\"migrate-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"migration-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"migration-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-migrate-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-migrate-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" id=\"submit-icon\" style=\"font-size:18px;\">cloud_done</span>\n <span id=\"submit-text\">Register & Start Migration</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: 'Register & Migrate to Cloud',\n content: modalContent,\n size: 'modal-md'\n });\n\n modalContent.querySelector('#btn-migrate-cancel').addEventListener('click', close);\n\n const form = modalContent.querySelector('#convert-cloud-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n\n const errorEl = modalContent.querySelector('#migration-error');\n const errorTextEl = modalContent.querySelector('#migration-error-text');\n const submitBtn = modalContent.querySelector('#btn-migrate-submit');\n const cancelBtn = modalContent.querySelector('#btn-migrate-cancel');\n const submitText = modalContent.querySelector('#submit-text');\n const submitIcon = modalContent.querySelector('#submit-icon');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n submitText.textContent = 'Migrating to Cloud...';\n submitIcon.className = 'material-icons-outlined spinner';\n submitIcon.textContent = 'sync';\n submitIcon.style.animation = 'spin 1s linear infinite';\n\n const companyName = modalContent.querySelector('#migrate-company-name').value.trim();\n const adminName = modalContent.querySelector('#migrate-admin-name').value.trim();\n const adminPhone = modalContent.querySelector('#migrate-admin-phone').value.trim();\n const email = modalContent.querySelector('#migrate-admin-email').value.trim();\n const password = modalContent.querySelector('#migrate-admin-password').value;\n\n try {\n const settings = store.getSettings();\n settings.name = companyName;\n await store.saveSettings(settings);\n\n const { supabase } = await import('../utils/supabase.js');\n\n const { data: authData, error: authErr } = await supabase.auth.signUp({\n email,\n password,\n options: {\n data: {\n name: adminName,\n phone: adminPhone\n }\n }\n });\n if (authErr) throw authErr;\n\n if (!authData.user) {\n throw new Error('Verification required or signup was blocked. Check your email inbox.');\n }\n\n const { data: companyId, error: rpcError } = await supabase.rpc('create_company_and_admin', {\n user_id: authData.user.id,\n company_name: companyName,\n admin_name: adminName,\n admin_phone: adminPhone\n });\n if (rpcError) throw rpcError;\n\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n await store.migrateLocalToCloud(companyId, authData.user.id);\n\n if (activeAccountId) {\n const localAccountsKey = 'relay_accounts';\n let localAccounts = [];\n try {\n const stored = localStorage.getItem(localAccountsKey);\n if (stored) {\n localAccounts = JSON.parse(stored);\n }\n } catch (e) {\n console.error('Error reading local accounts:', e);\n }\n localAccounts = localAccounts.filter(a => a.id !== activeAccountId);\n localStorage.setItem(localAccountsKey, JSON.stringify(localAccounts));\n\n store.deleteLocalAccountData(activeAccountId);\n }\n\n const { data: profile, error: profileErr } = await supabase\n .from('profiles')\n .select('*')\n .eq('id', authData.user.id)\n .single();\n if (profileErr) throw profileErr;\n\n const user = {\n id: profile.id,\n companyId: profile.company_id,\n name: profile.name,\n role: profile.role,\n userTypeName: 'Admin',\n userTypeId: `${profile.company_id}_ut_admin`,\n color: profile.color || '#FF5C00',\n theme: 'light'\n };\n\n localStorage.setItem('currentUser', JSON.stringify(user));\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Migration completed successfully! Redirecting...', 'success');\n close();\n\n setTimeout(() => {\n window.location.hash = '#/';\n window.location.reload();\n }, 1500);\n\n } catch (err) {\n console.error('Migration failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during migration.';\n errorEl.style.display = 'flex';\n\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n submitText.textContent = 'Register & Start Migration';\n submitIcon.className = 'material-icons-outlined';\n submitIcon.textContent = 'cloud_done';\n submitIcon.style.animation = '';\n }\n });\n };\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''} ${isUsersDisabled ? 'disabled-local' : ''}\" data-tab=\"users\" ${isUsersDisabled ? 'data-tooltip=\"Requires Cloud Account or Local Server\" data-tooltip-pos=\"top\"' : ''}>Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''} ${isPortalDisabled ? 'disabled-local' : ''}\" data-tab=\"portal\" ${isPortalDisabled ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''}>Customer Portal</button>\n <button class=\"tab ${activeTab === 'invoices_quotes' ? 'active' : ''}\" data-tab=\"invoices_quotes\">Quotes &amp; Invoices</button>\n <button class=\"tab ${activeTab === 'folder_sync' ? 'active' : ''} ${isFolderSyncDisabled ? 'disabled-local' : ''}\" data-tab=\"folder_sync\" ${isFolderSyncDisabled ? 'data-tooltip=\"Requires Local Folder Storage\" data-tooltip-pos=\"top\"' : ''}>Folder Sync</button>\n <button class=\"tab ${activeTab === 'ai_assistant' ? 'active' : ''} ${isAIAssistantDisabled ? 'disabled-local' : ''}\" data-tab=\"ai_assistant\" ${isAIAssistantDisabled ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''}>AI Assistant</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n if (tab.classList.contains('disabled-local')) return;\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'folder_sync') {\n renderFolderSyncTab(tc);\n return;\n }\n\n if (activeTab === 'ai_assistant') {\n renderAIAssistantTab(tc);\n return;\n }\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n\n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Standard Logo (Large) using Canvas to ~600x300 max bounding box\n compressImage(re.target.result, 600, 300).then((compressed) => {\n pendingLogo = compressed;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Small/Shrunk Logo using Canvas to ~200x100 max bounding box\n compressImage(re.target.result, 200, 100).then((compressed) => {\n pendingLogoSmall = compressed;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n\n\n tc.querySelector('#btn-save-company').addEventListener('click', async () => {\n const saveBtn = tc.querySelector('#btn-save-company');\n const originalHtml = saveBtn.innerHTML;\n saveBtn.disabled = true;\n saveBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving Changes...';\n\n try {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n await store.saveSettings(settings);\n showToast('Company information saved successfully to database', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n render();\n } catch (err) {\n console.error('Error saving company profile settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n saveBtn.disabled = false;\n saveBtn.innerHTML = originalHtml;\n }\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n const pendingResets = store.getAll('passwordResetRequests') || [];\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div style=\"background:rgba(59, 130, 246, 0.1); border-left:4px solid #3b82f6; padding:12px 16px; margin-bottom:var(--space-md); border-radius:4px; font-size:13px; color:#f8fafc; display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:#3b82f6; font-size:18px;\">info</span>\n <span>Your company login code is <strong style=\"color:#60a5fa\">${companySlug}</strong>. Technicians log in using <strong style=\"color:#60a5fa\">username@${companySlug}</strong>.</span>\n </span>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Username</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n ${ut?.id !== 'ut_admin' ? `\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n ` : ''}\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\">\n <h4 style=\"margin:0\">Pending Password Reset Requests</h4>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Username/Email</th>\n <th>Requested On</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:24px\">No pending reset requests</td></tr>' : ''}\n ${pendingResets.filter(r => r.status === 'pending').map(r => {\n const tech = techs.find(t => t.id === r.technician_id) || {};\n const requestedAt = r.requested_at ? new Date(r.requested_at).toLocaleString() : 'Unknown';\n return `\n <tr>\n <td class=\"font-medium\">${tech.name || 'Unknown'}</td>\n <td class=\"text-secondary\">${r.employee_id || tech.username || ''}</td>\n <td class=\"text-tertiary\">${requestedAt}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-approve-reset\" data-id=\"${r.id}\" data-tech-id=\"${tech.id}\">Approve &amp; Reset</button>\n <button class=\"btn btn-sm btn-ghost text-danger btn-deny-reset\" data-id=\"${r.id}\">Deny</button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n ${isLocalMode ? '' : `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `}\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => {\n openUserModal();\n });\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-approve-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const techId = e.currentTarget.dataset.techId;\n const t = store.getById('technicians', techId);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <p>Approve password reset request for <strong>${t.name}</strong>? Please enter their new password below:</p>\n <div class=\"form-group\" style=\"margin-top:12px\">\n <label class=\"form-label\">New Password</label>\n <input type=\"password\" id=\"admin-reset-pwd-input\" class=\"form-input\" placeholder=\"Min. 6 characters\" minlength=\"6\" autofocus />\n </div>\n `;\n showModal({\n title: 'Approve Password Reset',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reset Password', className: 'btn-primary', onClick: c => {\n const pwdInput = document.getElementById('admin-reset-pwd-input');\n const newPwd = pwdInput ? pwdInput.value : '';\n if (!newPwd || newPwd.length < 6) {\n showToast('Password must be at least 6 characters.', 'error');\n return;\n }\n \n // Update technician password\n store.update('technicians', techId, { password: newPwd });\n \n // Approve the request\n store.update('passwordResetRequests', id, { status: 'approved', updated_at: new Date().toISOString() });\n \n showToast(`Password updated for ${t.name}`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-deny-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n store.update('passwordResetRequests', id, { status: 'denied', updated_at: new Date().toISOString() });\n showToast('Password reset request denied', 'info');\n renderContent();\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax Rates</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\" style=\"display:flex; flex-direction:column; gap:8px\">\n <label class=\"form-label\" style=\"display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:0\">\n <input type=\"checkbox\" id=\"tax-enabled\" style=\"width:16px; height:16px; margin:0\" ${settings.taxEnabled !== false ? 'checked' : ''} />\n Enable GST / Sales Tax\n </label>\n <div style=\"display:${settings.taxEnabled !== false ? 'flex' : 'none'}; align-items:center; gap:8px; margin-top:4px\" id=\"tax-rate-container\">\n <input class=\"form-input\" id=\"tax-rate\" type=\"number\" value=\"${settings.taxRate !== undefined ? settings.taxRate : 10}\" style=\"width:100px\" min=\"0\" max=\"100\" step=\"0.1\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // Toggle tax rate container visibility\n tc.querySelector('#tax-enabled')?.addEventListener('change', (e) => {\n const container = tc.querySelector('#tax-rate-container');\n if (container) {\n container.style.display = e.target.checked ? 'flex' : 'none';\n }\n });\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#save-tax-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const taxEnabled = tc.querySelector('#tax-enabled').checked;\n const taxRate = parseFloat(tc.querySelector('#tax-rate').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.taxEnabled = taxEnabled;\n settings.taxRate = taxRate;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n await store.saveSettings(settings);\n showToast('Financial and Rate settings saved successfully', 'success');\n renderContent();\n } catch (err) {\n console.error('Error saving tax and rate settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-portal-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n await store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving customer portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'system') {\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n const currentTheme = (currentUser && currentUser.id) ? (currentUser.theme || localStorage.getItem(`simpro_theme_${currentUser.id}`) || 'light') : 'light';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Left Column -->\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg)\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n </p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n ${currentUser.role === 'admin' ? `\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed a complete demonstration dataset\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed Demonstration Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n <button class=\"btn btn-danger\" id=\"btn-delete-company\" data-tooltip=\"Permanently delete the entire company profile and all data\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; background:var(--color-danger); border-color:var(--color-danger); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span> Delete Company Profile\n </button>\n\n ${isLocalMode ? `\n <div style=\"margin-top:16px; padding-top:12px; border-top:1px dashed rgba(220, 38, 38, 0.2)\">\n <h6 style=\"color:var(--color-warning); margin-bottom:8px; font-weight:600; display:flex; align-items:center; gap:4px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">warning</span> Irreversible Profile Conversions\n </h6>\n <p style=\"font-size:11px; color:var(--text-secondary); margin-bottom:12px; line-height:1.3;\">\n Convert your deployment type. These transitions are permanent and one-way.\n </p>\n ${localDeploymentType === 'single_user' ? `\n <button class=\"btn\" id=\"btn-convert-multiuser\" style=\"width:100%; justify-content:center; margin-bottom:12px; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">lan</span> Convert to Multi-User Local Network Sync\n </button>\n ` : ''}\n <button class=\"btn\" id=\"btn-convert-cloud-action\" style=\"width:100%; justify-content:center; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">cloud_upload</span> Convert to Cloud Sync\n </button>\n </div>\n ` : ''}\n </div>\n ` : '<div style=\"font-size:12.5px; color:var(--text-tertiary)\">Administrative actions are restricted to company administrators.</div>'}\n </div>\n </div>\n </div>\n\n <!-- Local Data Backup -->\n ${!isLocalMode && currentUser.role === 'admin' ? `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary)\">backup</span>\n <h4 style=\"margin:0;\">Local Data Backup</h4>\n </div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:12px;\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm); line-height:1.4; margin:0;\">\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n </p>\n <div id=\"backup-status-container\"></div>\n </div>\n </div>\n ` : ''}\n </div>\n\n <!-- Right Column -->\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">System Color Theme</label>\n <select class=\"form-select\" id=\"system-theme-select\" style=\"width:100%\">\n ${Object.entries(THEMES).map(([key, val]) => `\n <option value=\"${key}\" ${currentTheme === key ? 'selected' : ''}>${val.name}</option>\n `).join('')}\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Select a color profile and visual background effect for your user interface.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', async (e) => {\n const select = e.target;\n select.disabled = true;\n try {\n const settings = store.getSettings();\n settings.tooltipPreference = select.value;\n await store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved successfully', 'success');\n } catch (err) {\n console.error('Error saving tooltip preference:', err);\n showToast('Failed to save preferences: ' + (err.message || err), 'error');\n } finally {\n select.disabled = false;\n }\n });\n\n tc.querySelector('#system-theme-select')?.addEventListener('change', (e) => {\n applyTheme(e.target.value, true);\n showToast('System theme updated successfully', 'success');\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a complete demonstration dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load a complete, realistic demonstration dataset representing an active trade business (5 customers, 5 quotes/jobs per customer, 5 assets per customer, 5 materials, 5 users/timesheets, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for testing and walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed Demonstration Data\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Demo Data\",\n className: \"btn-primary\",\n onClick: async (close) => {\n close();\n showToast('Seeding demonstration data...', 'info');\n await seedMinimalData();\n showToast('Demonstration data seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-delete-company')?.addEventListener('click', () => {\n // Modal Warning 1 of 2\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">warning</span>\n <span>You are about to permanently delete the entire company profile for <strong>${escapeHTML(store.getSettings().name || '')}</strong> and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).</span>\n </div>\n <p style=\"font-size:12.5px; color:var(--text-secondary); margin-bottom:12px\">\n This action is final, irreversible, and cannot be undone under any circumstances.\n </p>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Do you want to proceed to password confirmation?</p>\n `;\n\n showModal({\n title: \"Delete Company Profile - Step 1 of 2\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (closeModal) => closeModal()\n },\n {\n label: \"Proceed to Confirm\",\n className: \"btn-danger\",\n onClick: (closeModal) => {\n closeModal();\n \n // Modal Warning 2 of 2\n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <form id=\"delete-company-confirm-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <p style=\"margin-bottom:8px; font-weight:600; color:var(--color-danger)\">FINAL CONFIRMATION (2 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">gavel</span>\n <span>To authorize the permanent destruction of this company, you must enter the Administrator password.</span>\n </div>\n\n <div class=\"form-group\" style=\"margin-top:12px;\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Password</label>\n <input class=\"form-input\" type=\"password\" id=\"delete-confirm-password\" required placeholder=\"Enter admin password to proceed\" />\n </div>\n\n <div id=\"delete-confirm-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"delete-confirm-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-delete-abort\">Abort Deletion</button>\n <button type=\"submit\" class=\"btn btn-danger\" id=\"btn-delete-confirm-submit\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span>\n <span>Permanently Delete Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close: close2 } = showModal({\n title: \"⚠️ Confirm Company Deletion - Step 2 of 2\",\n content: content2,\n size: \"modal-md\"\n });\n\n content2.querySelector('#btn-delete-abort').addEventListener('click', close2);\n\n const form2 = content2.querySelector('#delete-company-confirm-form');\n form2.addEventListener('submit', async (ev) => {\n ev.preventDefault();\n\n const errorEl = content2.querySelector('#delete-confirm-error');\n const errorTextEl = content2.querySelector('#delete-confirm-error-text');\n const submitBtn = content2.querySelector('#btn-delete-confirm-submit');\n const abortBtn = content2.querySelector('#btn-delete-abort');\n const passwordInput = content2.querySelector('#delete-confirm-password').value;\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n abortBtn.disabled = true;\n const origSubmitText = submitBtn.innerHTML;\n submitBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Deleting...';\n\n try {\n let passwordVerified = false;\n\n if (isLocalMode) {\n if (localDeploymentType === 'single_user') {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acct = storedAccounts.find(a => a.id === activeAccountId);\n if (acct && acct.hasPassword) {\n const hashedInput = await hashPassword(passwordInput);\n if (hashedInput === acct.passwordHash) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n // No password set\n passwordVerified = true;\n }\n } else {\n // local_multiuser\n const techs = store.getAll('technicians') || [];\n const currentTech = techs.find(t => t.id === currentUser.id);\n const expectedPassword = currentTech ? currentTech.password : '123456';\n if (passwordInput === expectedPassword) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n }\n } else {\n // Cloud mode\n const { supabase } = await import('../utils/supabase.js');\n const { data: { user } } = await supabase.auth.getUser();\n if (user && user.email) {\n const { error } = await supabase.auth.signInWithPassword({\n email: user.email,\n password: passwordInput\n });\n if (!error) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n throw new Error('Could not retrieve logged-in cloud user credentials.');\n }\n }\n\n if (passwordVerified) {\n // 1. If cloud mode, delete the company row in Supabase\n if (!isLocalMode) {\n const { supabase } = await import('../utils/supabase.js');\n const { error: deleteErr } = await supabase.from('companies').delete().eq('id', store.companyId);\n if (deleteErr) {\n throw new Error('Failed to delete cloud company: ' + deleteErr.message);\n }\n }\n\n // 2. Trigger store.clearAll()\n store.clearAll();\n\n // 3. Clear local account launcher keys if local mode\n if (isLocalMode) {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const updatedAccounts = storedAccounts.filter(a => a.id !== activeAccountId);\n await storageSet('relay_accounts', updatedAccounts);\n }\n }\n\n // 4. Remove session user & active account\n localStorage.removeItem('currentUser');\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Company profile deleted successfully.', 'success');\n close2();\n\n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n } catch (err) {\n console.error('Company deletion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during verification.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n abortBtn.disabled = false;\n submitBtn.innerHTML = origSubmitText;\n }\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-convert-multiuser')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n\n const techs = store.getAll('technicians') || [];\n const companyId = store.companyId || '';\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const existingAdmin = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n const currentTechName = existingAdmin ? existingAdmin.name : (currentUser.name && currentUser.name !== 'Local Admin' ? currentUser.name : '');\n const currentTechUsernameOrEmail = existingAdmin ? (existingAdmin.email || existingAdmin.username || '') : '';\n\n content.innerHTML = `\n <form id=\"convert-multiuser-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Multi-User Local Network Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a single-user Local Admin profile.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"multiuser-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"e.g. Apex Power Services\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"multiuser-admin-name\" required value=\"${escapeHTML(currentTechName)}\" placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Email Address (Required)</label>\n <input class=\"form-input\" type=\"email\" id=\"multiuser-admin-email\" required value=\"${escapeHTML(currentTechUsernameOrEmail)}\" placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Admin Password</label>\n <input class=\"form-input\" type=\"password\" id=\"multiuser-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"multiuser-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"multiuser-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-multiuser-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-multiuser-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">lan</span>\n <span>Convert Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: \"Convert to Multi-User Local Sync\",\n content: content,\n size: \"modal-md\"\n });\n\n content.querySelector('#btn-multiuser-cancel').addEventListener('click', close);\n\n const form = content.querySelector('#convert-multiuser-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n const errorEl = content.querySelector('#multiuser-error');\n const errorTextEl = content.querySelector('#multiuser-error-text');\n const submitBtn = content.querySelector('#btn-multiuser-submit');\n const cancelBtn = content.querySelector('#btn-multiuser-cancel');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n\n const businessName = content.querySelector('#multiuser-company-name').value.trim();\n const adminName = content.querySelector('#multiuser-admin-name').value.trim();\n const adminEmail = content.querySelector('#multiuser-admin-email').value.trim();\n const adminPassword = content.querySelector('#multiuser-admin-password').value;\n\n try {\n if (!businessName || !adminName || !adminEmail || !adminPassword) {\n throw new Error('All fields are required.');\n }\n if (adminPassword.length < 6) {\n throw new Error('Password must be at least 6 characters.');\n }\n\n // 1. Update settings\n const settings = store.getSettings();\n settings.name = businessName;\n settings.localDeploymentType = 'multi_user';\n await store.saveSettings(settings);\n\n // 2. Update launcher accounts businessName\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acctIndex = storedAccounts.findIndex(a => a.id === activeAccountId);\n if (acctIndex !== -1) {\n storedAccounts[acctIndex].businessName = businessName;\n await storageSet('relay_accounts', storedAccounts);\n }\n }\n\n // 3. Create or update admin user in technicians\n const companyId = store.companyId;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const techs = store.getAll('technicians') || [];\n let adminTech = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n if (!adminTech) {\n adminTech = {\n id: `${companyId}_tech_admin_created`,\n role: 'Admin',\n color: '#FF5C00',\n userTypeId: adminTypeId,\n payRate: 100.00,\n phone: ''\n };\n techs.push(adminTech);\n }\n\n adminTech.name = adminName;\n adminTech.username = adminEmail.split('@')[0];\n adminTech.email = adminEmail;\n adminTech.password = adminPassword;\n\n store.save('technicians', techs);\n\n // 4. Update currentUser session in localStorage\n const localUser = {\n id: adminTech.id,\n companyId: companyId,\n name: adminTech.name,\n role: 'admin',\n userTypeName: 'Admin',\n userTypeId: adminTech.userTypeId,\n color: adminTech.color || '#FF5C00',\n theme: 'light'\n };\n localStorage.setItem('currentUser', JSON.stringify(localUser));\n\n showToast('Converted to Multi-User Local Network Sync. Reloading...', 'success');\n close();\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n setTimeout(() => window.location.reload(), 1200);\n } catch (err) {\n console.error('Conversion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during conversion.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n }\n });\n });\n\n tc.querySelector('#btn-convert-cloud-action')?.addEventListener('click', () => {\n openMigrationModal();\n });\n\n const renderBackupStatus = () => {\n const bsc = tc.querySelector('#backup-status-container');\n if (!bsc) return;\n\n const hasHandle = !!store.backupDirHandle;\n const isPermissionGranted = store.backupDirPermissionGranted;\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const formattedLastBackup = lastBackup ? new Date(lastBackup).toLocaleString() : 'Never';\n const isSupported = typeof window !== 'undefined' && window.showDirectoryPicker;\n\n if (!isSupported) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-danger); line-height:1.4;\">\n <strong style=\"display:block; margin-bottom:4px;\">Browser Local Folder Access Unsupported</strong>\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n </div>\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:12px; border-radius:6px; margin-bottom:12px; color:var(--text-secondary); display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary);\">folder_off</span>\n <div style=\"font-size:12.5px;\">No backup folder configured.</div>\n </div>\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">folder</span> Choose Backup Folder...\n </button>\n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-warning); line-height:1.4; margin-bottom:12px;\">\n <strong>Permission Required</strong><br/>\n Access permission to <strong>${escapeHTML(store.backupDirHandle.name)}</strong> has expired. Please re-authorize.\n </div>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-warning\" id=\"btn-backup-auth\" style=\"flex:1; justify-content:center;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize & Backup\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n `;\n } else {\n bsc.innerHTML = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-success); line-height:1.4; margin-bottom:12px;\">\n <strong>Backup Configured</strong><br/>\n Folder: <strong>${escapeHTML(store.backupDirHandle.name)}</strong><br/>\n Last Backup: <strong>${formattedLastBackup}</strong>\n </div>\n <div style=\"display:flex; flex-direction:column; gap:8px;\">\n <button class=\"btn btn-primary\" id=\"btn-backup-now\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">backup</span> Backup Now\n </button>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"flex:1; justify-content:center; border:1px solid var(--border-color);\">\n <span class=\"material-icons-outlined\">folder</span> Change Folder...\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n </div>\n `;\n }\n\n // Attach event listeners for backup buttons inside container\n bsc.querySelector('#btn-backup-pick')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setBackupDirectory(handle);\n showToast('Backup directory selected successfully.', 'success');\n showToast('Running initial backup...', 'info');\n await store.backupToFolder(handle);\n showToast('Backup completed successfully!', 'success');\n renderBackupStatus();\n } catch (err) {\n console.error('Failed to select backup directory:', err);\n if (err.name !== 'AbortError') {\n showToast('Failed to configure backup: ' + err.message, 'error');\n }\n }\n });\n\n bsc.querySelector('#btn-backup-auth')?.addEventListener('click', async () => {\n const granted = await store.verifyBackupDirPermission(true);\n if (granted) {\n showToast('Permission re-authorized. Backing up...', 'info');\n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n showToast('Backup failed: ' + err.message, 'error');\n }\n } else {\n showToast('Failed to acquire write permission.', 'error');\n }\n renderBackupStatus();\n });\n\n bsc.querySelector('#btn-backup-now')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Backing up...';\n \n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Backup failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n renderBackupStatus();\n }\n });\n\n bsc.querySelector('#btn-backup-disconnect')?.addEventListener('click', async () => {\n await store.setBackupDirectory(null);\n showToast('Backup folder disconnected.', 'info');\n renderBackupStatus();\n });\n };\n\n if (!isLocalMode && currentUser.role === 'admin') {\n renderBackupStatus();\n }\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', username: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Username</label>\n <input class=\"form-input\" id=\"u-username\" value=\"${t.username || (t.email ? t.email.split('@')[0] : '')}\" ${editId ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''} placeholder=\"e.g. joshua\" />\n ${!editId ? `\n <div style=\"font-size: 11px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500;\">\n Company login code is: <strong style=\"color: var(--color-primary)\">${companySlug}</strong>. User will log in with <strong style=\"color: var(--color-primary)\">username@${companySlug}</strong>\n </div>\n ` : ''}\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">${editId ? 'Reset Password (leave blank to keep current)' : 'Temporary Password (assigned for worker login)'}</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"${editId ? '••••••••' : 'Min. 6 characters'}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\" ${(t.userTypeId === 'ut_admin' || (t.userTypeId && t.userTypeId.endsWith('_ut_admin'))) ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''}>\n <option value=\"\">-- Select --</option>\n ${userTypes\n .filter(ut => (!ut.id.endsWith('_ut_admin') && ut.id !== 'ut_admin') || t.userTypeId === ut.id)\n .map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value.trim();\n const username = document.getElementById('u-username').value.trim();\n const role = document.getElementById('u-role').value.trim();\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = document.getElementById('u-password')?.value || '';\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!username) { showToast('Username required', 'error'); return; }\n if (username.includes('@')) { showToast('Username cannot contain @ symbol', 'error'); return; }\n \n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (password && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n const updates = { name, username, role, userTypeId, color, payRate };\n if (password) {\n updates.password = password;\n }\n\n if (editId) {\n await store.update('technicians', editId, updates);\n } else {\n await store.create('technicians', updates);\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = async () => {\n const btn = tc.querySelector('#btn-save-materials');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n await store.saveSettings(updatedSettings);\n showToast('Material settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving material settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-00023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-00129',\n originalQuoteNumber: 'Q-00412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' },\n ballet: { name: 'Ballet Pointe', preset: 'ballet', accentColor: '#e58799', headerBg: '#5c2c36', accentTint: '#fff5f6', fontFamily: 'serif' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-company-name\" style=\"width:16px; height:16px\" ${dt.hideCompanyName ? 'checked' : ''} />\n <label for=\"theme-hide-company-name\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide company name text (use logo only)</label>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Variation</label>\n <select class=\"form-select\" id=\"theme-logo-source\" style=\"width:100%\">\n <option value=\"large\" ${dt.logoSource === 'large' ? 'selected' : ''}>Standard Logo (Large)</option>\n <option value=\"small\" ${dt.logoSource === 'small' ? 'selected' : ''}>Small / Shrunk Logo (Small)</option>\n </select>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:8px 0\"></div>\n <div class=\"form-group ${isLocalMode ? 'disabled-local' : ''}\" ${isLocalMode ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''} style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-brand-logo\" style=\"width:16px; height:16px\" ${dt.hideBrandLogo ? 'checked' : ''} ${isLocalMode ? 'disabled' : ''} />\n <label for=\"theme-hide-brand-logo\" class=\"form-label\" style=\"margin:0; cursor:${isLocalMode ? 'not-allowed' : 'pointer'}\">Remove Relay-dispatch brand logo from document footers</label>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Hide Company Name toggle\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) {\n hideCompanyNameChk.addEventListener('change', () => {\n dt.hideCompanyName = hideCompanyNameChk.checked;\n updatePreview();\n });\n }\n\n // Logo Variation toggle\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) {\n logoSourceSelect.addEventListener('change', (e) => {\n dt.logoSource = e.target.value;\n updatePreview();\n });\n }\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Hide Brand Logo toggle\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) {\n hideBrandLogoChk.addEventListener('change', () => {\n dt.hideBrandLogo = hideBrandLogoChk.checked;\n updatePreview();\n });\n }\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-theme-save');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n await store.saveSettings(freshSettings);\n showToast('Document customization settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving theme settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n hideCompanyName: false,\n hideBrandLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) hideCompanyNameChk.checked = !!dt.hideCompanyName;\n\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) logoSourceSelect.value = dt.logoSource || 'large';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) hideBrandLogoChk.checked = !!dt.hideBrandLogo;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n const logoUrl = dt.logoSource === 'small' ? (settings.logoSmall || settings.logo) : (settings.logo || settings.logoSmall);\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.src = logoUrl || '';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // Company Name visibility\n const companyNameEl = frameDoc.querySelector('.pdf-company-name');\n if (companyNameEl) {\n companyNameEl.style.display = dt.hideCompanyName ? 'none' : 'block';\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n\n const footerBrandingEl = frameDoc.querySelector('.pdf-footer-branding');\n if (footerBrandingEl) {\n const hideBrandLogo = isLocalMode ? false : !!dt.hideBrandLogo;\n footerBrandingEl.style.display = hideBrandLogo ? 'none' : 'flex';\n }\n }\n\n renderMarkup();\n }\n\n function renderFolderSyncTab(tc) {\n const isSupported = typeof window !== 'undefined' && (\n (window.indexedDB && window.showDirectoryPicker) ||\n (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem)\n );\n\n const isCapacitor = typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem;\n\n const activeAccountId = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n const isLocalCompany = !!activeAccountId;\n\n function render() {\n console.log('[DEBUG Settings] render: isLocalCompany =', isLocalCompany, 'store.dirHandle =', store.dirHandle, 'permissionGranted =', store.folderSyncPermissionGranted);\n const isEnabled = isLocalCompany ? true : store.folderSyncEnabled;\n const isPermissionGranted = store.folderSyncPermissionGranted;\n const hasHandle = !!store.dirHandle;\n\n let statusHtml = '';\n if (!isSupported) {\n statusHtml = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:16px; border-radius:4px; color:var(--color-danger); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:8px; font-weight:600; margin-bottom:4px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Browser Directory Access Unsupported</span>\n </div>\n <p style=\"font-size:var(--font-size-sm); margin:0; line-height:1.4;\">\n Your current browser does not support local folder access. To enable direct folder synchronization, please run this app in a Chromium-based browser (Chrome, Edge, Opera, or as a compiled native app). High-capacity IndexedDB storage remains active.\n </p>\n </div>\n `;\n } else if (!isEnabled) {\n statusHtml = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:16px; border-radius:6px; color:var(--text-secondary); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px; color:var(--text-tertiary);\">folder_off</span>\n <div>\n <div style=\"font-weight:600; color:var(--text-primary); margin-bottom:2px;\">Folder Synchronization Inactive</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4;\">All data is currently stored in your browser's private IndexedDB database sandbox.</p>\n </div>\n </div>\n `;\n } else if (isCapacitor) {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">cloud_done</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Capacitor Direct Folder Sync Active</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Data is synchronizing directly to the application's native <strong>Documents/RelayDispatchData</strong> directory on your iPad/device.\n </p>\n </div>\n </div>\n `;\n } else if (!hasHandle) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">warning</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">No Directory Selected</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">Please select a directory folder on your computer to begin syncing.</p>\n </div>\n </div>\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:12px; margin-bottom:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">lock</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Access Permission Suspended</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n The browser requires re-authorization to read/write files in <strong>${escapeHTML(store.dirHandle.name)}</strong>.\n </p>\n </div>\n </div>\n <button class=\"btn btn-warning\" id=\"btn-reauthorize-dir\" style=\"margin-left:44px;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize Access\n </button>\n </div>\n `;\n } else {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">check_circle</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Folder Sync Active & Synchronized</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Active Folder: <strong>${escapeHTML(store.dirHandle.name)}</strong>. All data edits are writing dynamically.\n </p>\n </div>\n </div>\n `;\n }\n\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <!-- Folder Configuration -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Direct Directory Sync</h4></div>\n <div class=\"card-body\">\n ${statusHtml}\n\n <div class=\"form-group\" style=\"margin-bottom:var(--space-lg);\">\n <label class=\"form-label\" style=\"font-weight:600; margin-bottom:8px;\">Synchronization Toggle</label>\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\">\n <input type=\"checkbox\" id=\"toggle-folder-sync\" ${isEnabled ? 'checked' : ''} ${(!isSupported || isLocalCompany) ? 'disabled' : ''} style=\"width:20px; height:20px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\" />\n <div>\n <span style=\"font-weight:500;\">Enable Direct Local Folder Storage</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">\n ${isLocalCompany ? 'Mandatory for offline/local company profile storage.' : 'Saves all data records and attachments straight to your machine.'}\n </div>\n </div>\n </label>\n </div>\n\n ${isEnabled && isSupported && !isCapacitor ? `\n <div style=\"display:flex; gap:12px; flex-wrap:wrap; border-top:1px solid var(--border-color); padding-top:var(--space-lg);\">\n <button class=\"btn btn-secondary\" id=\"btn-pick-dir\">\n <span class=\"material-icons-outlined\">folder</span> Choose Sync Folder...\n </button>\n ${hasHandle && isPermissionGranted ? `\n <button class=\"btn btn-secondary\" id=\"btn-force-sync\" data-tooltip=\"Overwrite folder JSONs with current memory cache\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">sync</span> Sync Now\n </button>\n ${!isLocalCompany ? `\n <button class=\"btn btn-danger\" id=\"btn-disconnect-dir\">\n <span class=\"material-icons-outlined\">link_off</span> Disconnect Folder\n </button>\n ` : ''}\n ` : ''}\n </div>\n ` : ''}\n </div>\n </div>\n\n <!-- Instructions Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>How Self-Hosting Works</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p>\n By linking a local folder, you establish a <strong>serverless self-hosted data hub</strong>.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">storage</span>\n <span><strong>Readable Data:</strong> Your jobs, quotes, and company configurations are saved in <code>data/*.json</code> files.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">photo_library</span>\n <span><strong>Physical Attachments:</strong> Uploaded photos and PDF catalogs are saved as standard image/PDF files under the <code>documents/</code> subfolder.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">sync_alt</span>\n <span><strong>Cloud Backups:</strong> Select a folder synced to <strong>OneDrive, Google Drive, or Dropbox</strong> to automatically back up and sync your data to the cloud.</span>\n </div>\n </div>\n </div>\n </div>\n `;\n\n // Event Listeners\n const toggle = tc.querySelector('#toggle-folder-sync');\n toggle?.addEventListener('change', async (e) => {\n if (isLocalCompany) {\n e.preventDefault();\n toggle.checked = true;\n return;\n }\n\n if (isCapacitor) {\n await store.setLocalDirectory(e.target.checked ? {} : null);\n showToast(e.target.checked ? 'Capacitor folder sync activated' : 'Folder sync deactivated', 'success');\n render();\n return;\n }\n\n if (e.target.checked) {\n // Trigger directory picker\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory configured successfully', 'success');\n } catch (err) {\n console.error('Directory selection cancelled or failed:', err);\n toggle.checked = false;\n showToast('Folder selection cancelled', 'info');\n }\n } else {\n await store.setLocalDirectory(null);\n showToast('Direct directory synchronization disabled', 'info');\n }\n render();\n });\n\n tc.querySelector('#btn-pick-dir')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory updated successfully', 'success');\n render();\n } catch (err) {\n console.error(err);\n }\n });\n\n tc.querySelector('#btn-reauthorize-dir')?.addEventListener('click', async () => {\n const granted = await store.verifyDirPermission(true);\n if (granted) {\n showToast('Folder access re-authorized successfully', 'success');\n } else {\n showToast('Failed to acquire write permissions', 'error');\n }\n render();\n });\n\n tc.querySelector('#btn-disconnect-dir')?.addEventListener('click', async () => {\n await store.setLocalDirectory(null);\n showToast('Local folder disconnected', 'info');\n render();\n });\n\n tc.querySelector('#btn-force-sync')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n btn.disabled = true;\n showToast('Writing cache data to local directory...', 'info');\n try {\n const collections = Object.keys(store.cache);\n await Promise.all(collections.map(col => store.writeCollectionToFolder(col, store.cache[col])));\n showToast('Folder sync completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Folder sync failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n }\n });\n }\n\n render();\n }\n\n function renderAIAssistantTab(tc) {\n const s = store.getSettings();\n const ai = s.ai || {\n enabled: false,\n apiKey: '',\n endpoint: 'https://api.deepseek.com/chat/completions',\n model: 'deepseek-chat',\n systemPrompt: 'You are Relay, an intelligent CRM co-pilot assistant. You help dispatchers manage jobs, quotes, invoices, and scheduling.'\n };\n\n function render() {\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>AI Assistant Configuration</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n \n <div class=\"form-group\">\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:pointer;\">\n <input type=\"checkbox\" id=\"ai-enabled\" ${ai.enabled ? 'checked' : ''} style=\"width:20px; height:20px; cursor:pointer;\" />\n <div>\n <span style=\"font-weight:600;\">Enable AI Assistant (DeepSeek)</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">Replaces the basic rule-based Relay co-pilot with a smart conversational LLM.</div>\n </div>\n </label>\n </div>\n\n <div id=\"ai-fields\" style=\"display: ${ai.enabled ? 'flex' : 'none'}; flex-direction:column; gap:16px; border-top:1px solid var(--border-color); padding-top:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">DeepSeek API Key</label>\n <input type=\"password\" class=\"form-input\" id=\"ai-apikey\" value=\"${ai.apiKey || ''}\" placeholder=\"sk-...\" />\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">Your API key is stored locally in your browser/sync directory and never sent to any third party other than the configured API endpoint.</div>\n </div>\n\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">API Endpoint</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-endpoint\" value=\"${ai.endpoint || 'https://api.deepseek.com/chat/completions'}\" placeholder=\"https://api.deepseek.com/chat/completions\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Model Name</label>\n <input type=\"text\" class=\"form-input\" id=\"ai-model\" value=\"${ai.model || 'deepseek-chat'}\" placeholder=\"deepseek-chat\" />\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Base System Prompt</label>\n <textarea class=\"form-input\" id=\"ai-systemprompt\" rows=\"4\" style=\"font-family:inherit; resize:vertical;\">${ai.systemPrompt || ''}</textarea>\n <div class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.</div>\n </div>\n\n <div style=\"display:flex; gap:12px; align-items:center; margin-top:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-test-ai\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">bolt</span> Test Connection\n </button>\n <span id=\"test-status\" style=\"font-size:13px; font-weight:500;\"></span>\n </div>\n <div id=\"test-error-details\" style=\"display:none; margin-top:8px; font-size:12px; color:var(--color-danger); background:var(--color-danger-bg); border-left:3px solid var(--color-danger); padding:8px; border-radius:4px; line-height:1.4;\"></div>\n </div>\n\n <div style=\"border-top:1px solid var(--border-color); padding-top:16px; display:flex; justify-content:flex-end;\">\n <button class=\"btn btn-primary\" id=\"btn-save-ai\">Save AI Config</button>\n </div>\n\n </div>\n </div>\n\n <!-- Helper instructions card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>Cheapest Smart AI</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p>\n <strong>DeepSeek</strong> is one of the most cost-efficient and high-performance language models currently available, making it perfect for lightweight co-pilot tasks.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">key</span>\n <span><strong>API Key:</strong> Get your key from <a href=\"https://platform.deepseek.com/\" target=\"_blank\" style=\"color:var(--color-primary); text-decoration:underline;\">platform.deepseek.com</a>. New accounts typically receive free trial credits.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">security</span>\n <span><strong>CORS Restrictions:</strong> Due to browser security, direct API calls to DeepSeek may be blocked. To bypass this:\n <ul style=\"margin:4px 0 0 16px; padding:0; list-style:disc; font-size:12px;\">\n <li>Route via a CORS proxy (e.g. <code>https://cors-anywhere.herokuapp.com/</code> prefix).</li>\n <li>Connect via an aggregator like <strong>OpenRouter</strong> (API Endpoint: <code>https://openrouter.ai/api/v1/chat/completions</code>).</li>\n <li>Use a local LLM runner like <strong>Ollama</strong> (API Endpoint: <code>http://localhost:11434/v1/chat/completions</code>).</li>\n </ul>\n </span>\n </div>\n </div>\n </div>\n </div>\n `;\n\n // Toggle display of fields based on enabled checkbox\n const enabledCheckbox = tc.querySelector('#ai-enabled');\n const fieldsContainer = tc.querySelector('#ai-fields');\n enabledCheckbox.addEventListener('change', (e) => {\n fieldsContainer.style.display = e.target.checked ? 'flex' : 'none';\n });\n\n // Save handler\n tc.querySelector('#btn-save-ai').addEventListener('click', () => {\n const enabled = tc.querySelector('#ai-enabled').checked;\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const systemPrompt = tc.querySelector('#ai-systemprompt').value.trim();\n\n if (enabled && !apiKey) {\n showToast('API Key is required when enabling the AI assistant.', 'error');\n return;\n }\n\n const settings = store.getSettings();\n settings.ai = {\n enabled,\n apiKey,\n endpoint,\n model,\n systemPrompt\n };\n\n store.saveSettings(settings);\n showToast('AI settings saved successfully', 'success');\n });\n\n // Test handler\n tc.querySelector('#btn-test-ai').addEventListener('click', async () => {\n const apiKey = tc.querySelector('#ai-apikey').value.trim();\n const endpoint = tc.querySelector('#ai-endpoint').value.trim();\n const model = tc.querySelector('#ai-model').value.trim();\n const statusEl = tc.querySelector('#test-status');\n const errEl = tc.querySelector('#test-error-details');\n\n const inElectron = !!(window.electronAPI && window.electronAPI.callDeepSeek);\n const hasEnvKey = !!(import.meta.env.VITE_DEEPSEEK_API_KEY);\n\n if (!apiKey && !inElectron && !hasEnvKey) {\n showToast('Enter an API key to test.', 'error');\n return;\n }\n\n statusEl.style.color = 'var(--text-secondary)';\n statusEl.textContent = 'Testing connection...';\n errEl.style.display = 'none';\n\n const startTime = Date.now();\n try {\n if (inElectron) {\n const data = await window.electronAPI.callDeepSeek({\n messages: [{ role: 'user', content: 'respond only with the word OK' }],\n endpoint,\n model,\n apiKey // Pass typed key if any\n });\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success (via Electron IPC)! Latency: ${latency}ms (Reply: \"${reply}\")`;\n } else {\n const res = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY}`\n },\n body: JSON.stringify({\n model: model || 'deepseek-chat',\n messages: [\n { role: 'user', content: 'respond only with the word OK' }\n ],\n max_tokens: 5\n })\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`HTTP ${res.status}: ${body}`);\n }\n\n const data = await res.json();\n const latency = Date.now() - startTime;\n const reply = data.choices?.[0]?.message?.content || 'No reply';\n statusEl.style.color = 'var(--color-success)';\n statusEl.textContent = `Success! Latency: ${latency}ms (Reply: \"${reply}\")`;\n }\n } catch (err) {\n console.error('AI test connection failed:', err);\n statusEl.style.color = 'var(--color-danger)';\n statusEl.textContent = 'Test failed';\n errEl.style.display = 'block';\n\n let errMsg = err.message;\n if (err.name === 'TypeError' && err.message.includes('Failed to fetch')) {\n errMsg = 'CORS Blocked or Offline. The browser blocked this cross-origin request. Please use a CORS proxy, an API gateway like OpenRouter, or check your internet connection.';\n }\n errEl.textContent = errMsg;\n }\n });\n }\n\n render();\n }\n\n\n\n"}, "d2d059ebb7e6818c7cf09140accf6564902469ee": {"t": 1785754189.308556, "n": 236915, "h": "e06067eb3c785116", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData, seedData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\nimport { applyTheme, THEMES } from '../utils/theme.js';\nimport { storageGet, storageSet } from '../utils/tauriStore.js';\n\n// Compress uploaded images using Canvas to avoid huge Base64 data payloads\nfunction compressImage(dataUrl, maxWidth, maxHeight) {\n return new Promise((resolve) => {\n if (!dataUrl) {\n resolve(dataUrl);\n return;\n }\n // SVG vectors are already lightweight; do not compress them\n if (dataUrl.startsWith('data:image/svg+xml')) {\n resolve(dataUrl);\n return;\n }\n const img = new Image();\n img.onload = () => {\n let width = img.width;\n let height = img.height;\n\n if (width > height) {\n if (width > maxWidth) {\n height = Math.round((height * maxWidth) / width);\n width = maxWidth;\n }\n } else {\n if (height > maxHeight) {\n width = Math.round((width * maxHeight) / height);\n height = maxHeight;\n }\n }\n\n const canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n\n const ctx = canvas.getContext('2d');\n ctx.drawImage(img, 0, 0, width, height);\n\n const isPng = dataUrl.includes('image/png');\n const format = isPng ? 'image/png' : 'image/jpeg';\n const quality = isPng ? undefined : 0.85;\n\n resolve(canvas.toDataURL(format, quality));\n };\n img.onerror = () => {\n resolve(dataUrl);\n };\n img.src = dataUrl;\n });\n}\n\n// Helper to hash password using SHA-256 Web Crypto API\nasync function hashPassword(password) {\n const msgBuffer = new TextEncoder().encode(password);\n const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);\n const hashArray = Array.from(new Uint8Array(hashBuffer));\n return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');\n}\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n const localDeploymentType = settings.localDeploymentType || 'single_user';\n\n const isUsersDisabled = isLocalMode && localDeploymentType === 'single_user';\n const isPortalDisabled = isLocalMode;\n const isFolderSyncDisabled = !isLocalMode;\n\n if (isUsersDisabled && activeTab === 'users') {\n activeTab = 'company';\n }\n if (isPortalDisabled && activeTab === 'portal') {\n activeTab = 'company';\n }\n if (isFolderSyncDisabled && activeTab === 'folder_sync') {\n activeTab = 'company';\n }\n\n const openMigrationModal = () => {\n const modalContent = document.createElement('div');\n modalContent.innerHTML = `\n <form id=\"convert-cloud-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Cloud Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a local/offline account. All data will be migrated to the secure cloud database.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"migrate-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"Company Name\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"migrate-admin-name\" required placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Phone Number</label>\n <input class=\"form-input\" id=\"migrate-admin-phone\" required placeholder=\"e.g. 0412 345 678\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Email Address (Username)</label>\n <input class=\"form-input\" type=\"email\" id=\"migrate-admin-email\" required placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Password</label>\n <input class=\"form-input\" type=\"password\" id=\"migrate-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"migration-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"migration-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-migrate-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-migrate-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" id=\"submit-icon\" style=\"font-size:18px;\">cloud_done</span>\n <span id=\"submit-text\">Register & Start Migration</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: 'Register & Migrate to Cloud',\n content: modalContent,\n size: 'modal-md'\n });\n\n modalContent.querySelector('#btn-migrate-cancel').addEventListener('click', close);\n\n const form = modalContent.querySelector('#convert-cloud-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n\n const errorEl = modalContent.querySelector('#migration-error');\n const errorTextEl = modalContent.querySelector('#migration-error-text');\n const submitBtn = modalContent.querySelector('#btn-migrate-submit');\n const cancelBtn = modalContent.querySelector('#btn-migrate-cancel');\n const submitText = modalContent.querySelector('#submit-text');\n const submitIcon = modalContent.querySelector('#submit-icon');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n submitText.textContent = 'Migrating to Cloud...';\n submitIcon.className = 'material-icons-outlined spinner';\n submitIcon.textContent = 'sync';\n submitIcon.style.animation = 'spin 1s linear infinite';\n\n const companyName = modalContent.querySelector('#migrate-company-name').value.trim();\n const adminName = modalContent.querySelector('#migrate-admin-name').value.trim();\n const adminPhone = modalContent.querySelector('#migrate-admin-phone').value.trim();\n const email = modalContent.querySelector('#migrate-admin-email').value.trim();\n const password = modalContent.querySelector('#migrate-admin-password').value;\n\n try {\n const settings = store.getSettings();\n settings.name = companyName;\n await store.saveSettings(settings);\n\n const { supabase } = await import('../utils/supabase.js');\n\n const { data: authData, error: authErr } = await supabase.auth.signUp({\n email,\n password,\n options: {\n data: {\n name: adminName,\n phone: adminPhone\n }\n }\n });\n if (authErr) throw authErr;\n\n if (!authData.user) {\n throw new Error('Verification required or signup was blocked. Check your email inbox.');\n }\n\n const { data: companyId, error: rpcError } = await supabase.rpc('create_company_and_admin', {\n user_id: authData.user.id,\n company_name: companyName,\n admin_name: adminName,\n admin_phone: adminPhone\n });\n if (rpcError) throw rpcError;\n\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n await store.migrateLocalToCloud(companyId, authData.user.id);\n\n if (activeAccountId) {\n const localAccountsKey = 'relay_accounts';\n let localAccounts = [];\n try {\n const stored = localStorage.getItem(localAccountsKey);\n if (stored) {\n localAccounts = JSON.parse(stored);\n }\n } catch (e) {\n console.error('Error reading local accounts:', e);\n }\n localAccounts = localAccounts.filter(a => a.id !== activeAccountId);\n localStorage.setItem(localAccountsKey, JSON.stringify(localAccounts));\n\n store.deleteLocalAccountData(activeAccountId);\n }\n\n const { data: profile, error: profileErr } = await supabase\n .from('profiles')\n .select('*')\n .eq('id', authData.user.id)\n .single();\n if (profileErr) throw profileErr;\n\n const user = {\n id: profile.id,\n companyId: profile.company_id,\n name: profile.name,\n role: profile.role,\n userTypeName: 'Admin',\n userTypeId: `${profile.company_id}_ut_admin`,\n color: profile.color || '#FF5C00',\n theme: 'light'\n };\n\n localStorage.setItem('currentUser', JSON.stringify(user));\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Migration completed successfully! Redirecting...', 'success');\n close();\n\n setTimeout(() => {\n window.location.hash = '#/';\n window.location.reload();\n }, 1500);\n\n } catch (err) {\n console.error('Migration failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during migration.';\n errorEl.style.display = 'flex';\n\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n submitText.textContent = 'Register & Start Migration';\n submitIcon.className = 'material-icons-outlined';\n submitIcon.textContent = 'cloud_done';\n submitIcon.style.animation = '';\n }\n });\n };\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''} ${isUsersDisabled ? 'disabled-local' : ''}\" data-tab=\"users\" ${isUsersDisabled ? 'data-tooltip=\"Requires Cloud Account or Local Server\" data-tooltip-pos=\"top\"' : ''}>Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''} ${isPortalDisabled ? 'disabled-local' : ''}\" data-tab=\"portal\" ${isPortalDisabled ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''}>Customer Portal</button>\n <button class=\"tab ${activeTab === 'invoices_quotes' ? 'active' : ''}\" data-tab=\"invoices_quotes\">Quotes &amp; Invoices</button>\n <button class=\"tab ${activeTab === 'folder_sync' ? 'active' : ''} ${isFolderSyncDisabled ? 'disabled-local' : ''}\" data-tab=\"folder_sync\" ${isFolderSyncDisabled ? 'data-tooltip=\"Requires Local Folder Storage\" data-tooltip-pos=\"top\"' : ''}>Folder Sync</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n if (tab.classList.contains('disabled-local')) return;\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'folder_sync') {\n renderFolderSyncTab(tc);\n return;\n }\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n\n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Standard Logo (Large) using Canvas to ~600x300 max bounding box\n compressImage(re.target.result, 600, 300).then((compressed) => {\n pendingLogo = compressed;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Small/Shrunk Logo using Canvas to ~200x100 max bounding box\n compressImage(re.target.result, 200, 100).then((compressed) => {\n pendingLogoSmall = compressed;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n\n\n tc.querySelector('#btn-save-company').addEventListener('click', async () => {\n const saveBtn = tc.querySelector('#btn-save-company');\n const originalHtml = saveBtn.innerHTML;\n saveBtn.disabled = true;\n saveBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving Changes...';\n\n try {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n await store.saveSettings(settings);\n showToast('Company information saved successfully to database', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n render();\n } catch (err) {\n console.error('Error saving company profile settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n saveBtn.disabled = false;\n saveBtn.innerHTML = originalHtml;\n }\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n const pendingResets = store.getAll('passwordResetRequests') || [];\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Schedule') return ['view', 'view_own', 'edit'].includes(key);\n if (mod === 'Quotes') return ['view', 'create', 'edit', 'delete', 'approve', 'convert', 'generate_pdf'].includes(key);\n if (mod === 'Jobs') {\n return ['view', 'create', 'edit', 'delete', 'book_time', 'view_invoices_tab', 'create_invoice', 'manage_tasks', 'view_timesheets_tab', 'manage_materials'].includes(key);\n }\n if (mod === 'Invoices') return ['view', 'create', 'send', 'void'].includes(key);\n if (mod === 'Customers') return ['view', 'create', 'edit', 'delete', 'manage_contacts'].includes(key);\n if (mod === 'Assets') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Stock') return ['view', 'create', 'edit', 'delete'].includes(key);\n if (mod === 'Purchase Orders') return ['view', 'create', 'approve'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'view', 'create', 'edit_all'].includes(key);\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n if (mod === 'Documents') return ['view', 'upload'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div style=\"background:rgba(59, 130, 246, 0.1); border-left:4px solid #3b82f6; padding:12px 16px; margin-bottom:var(--space-md); border-radius:4px; font-size:13px; color:#f8fafc; display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:#3b82f6; font-size:18px;\">info</span>\n <span>Your company login code is <strong style=\"color:#60a5fa\">${companySlug}</strong>. Technicians log in using <strong style=\"color:#60a5fa\">username@${companySlug}</strong>.</span>\n </span>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Username</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n ${ut?.id !== 'ut_admin' ? `\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n ` : ''}\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\">\n <h4 style=\"margin:0\">Pending Password Reset Requests</h4>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Username/Email</th>\n <th>Requested On</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:24px\">No pending reset requests</td></tr>' : ''}\n ${pendingResets.filter(r => r.status === 'pending').map(r => {\n const tech = techs.find(t => t.id === r.technician_id) || {};\n const requestedAt = r.requested_at ? new Date(r.requested_at).toLocaleString() : 'Unknown';\n return `\n <tr>\n <td class=\"font-medium\">${tech.name || 'Unknown'}</td>\n <td class=\"text-secondary\">${r.employee_id || tech.username || ''}</td>\n <td class=\"text-tertiary\">${requestedAt}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-approve-reset\" data-id=\"${r.id}\" data-tech-id=\"${tech.id}\">Approve &amp; Reset</button>\n <button class=\"btn btn-sm btn-ghost text-danger btn-deny-reset\" data-id=\"${r.id}\">Deny</button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n ${isLocalMode ? '' : `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `}\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => {\n openUserModal();\n });\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-approve-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const techId = e.currentTarget.dataset.techId;\n const t = store.getById('technicians', techId);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <p>Approve password reset request for <strong>${t.name}</strong>? Please enter their new password below:</p>\n <div class=\"form-group\" style=\"margin-top:12px\">\n <label class=\"form-label\">New Password</label>\n <input type=\"password\" id=\"admin-reset-pwd-input\" class=\"form-input\" placeholder=\"Min. 6 characters\" minlength=\"6\" autofocus />\n </div>\n `;\n showModal({\n title: 'Approve Password Reset',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reset Password', className: 'btn-primary', onClick: c => {\n const pwdInput = document.getElementById('admin-reset-pwd-input');\n const newPwd = pwdInput ? pwdInput.value : '';\n if (!newPwd || newPwd.length < 6) {\n showToast('Password must be at least 6 characters.', 'error');\n return;\n }\n \n // Update technician password\n store.update('technicians', techId, { password: newPwd });\n \n // Approve the request\n store.update('passwordResetRequests', id, { status: 'approved', updated_at: new Date().toISOString() });\n \n showToast(`Password updated for ${t.name}`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-deny-reset').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n store.update('passwordResetRequests', id, { status: 'denied', updated_at: new Date().toISOString() });\n showToast('Password reset request denied', 'info');\n renderContent();\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax Rates</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\" style=\"display:flex; flex-direction:column; gap:8px\">\n <label class=\"form-label\" style=\"display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:0\">\n <input type=\"checkbox\" id=\"tax-enabled\" style=\"width:16px; height:16px; margin:0\" ${settings.taxEnabled !== false ? 'checked' : ''} />\n Enable GST / Sales Tax\n </label>\n <div style=\"display:${settings.taxEnabled !== false ? 'flex' : 'none'}; align-items:center; gap:8px; margin-top:4px\" id=\"tax-rate-container\">\n <input class=\"form-input\" id=\"tax-rate\" type=\"number\" value=\"${settings.taxRate !== undefined ? settings.taxRate : 10}\" style=\"width:100px\" min=\"0\" max=\"100\" step=\"0.1\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // Toggle tax rate container visibility\n tc.querySelector('#tax-enabled')?.addEventListener('change', (e) => {\n const container = tc.querySelector('#tax-rate-container');\n if (container) {\n container.style.display = e.target.checked ? 'flex' : 'none';\n }\n });\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#save-tax-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const taxEnabled = tc.querySelector('#tax-enabled').checked;\n const taxRate = parseFloat(tc.querySelector('#tax-rate').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.taxEnabled = taxEnabled;\n settings.taxRate = taxRate;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n await store.saveSettings(settings);\n showToast('Financial and Rate settings saved successfully', 'success');\n renderContent();\n } catch (err) {\n console.error('Error saving tax and rate settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-portal-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n await store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving customer portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'system') {\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n const currentTheme = (currentUser && currentUser.id) ? (currentUser.theme || localStorage.getItem(`simpro_theme_${currentUser.id}`) || 'light') : 'light';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Left Column -->\n <div style=\"display:flex; flex-direction:column; gap:var(--space-lg)\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n </p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n ${currentUser.role === 'admin' ? `\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed a complete demonstration dataset\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed Demonstration Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n <button class=\"btn btn-danger\" id=\"btn-delete-company\" data-tooltip=\"Permanently delete the entire company profile and all data\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; background:var(--color-danger); border-color:var(--color-danger); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span> Delete Company Profile\n </button>\n\n ${isLocalMode ? `\n <div style=\"margin-top:16px; padding-top:12px; border-top:1px dashed rgba(220, 38, 38, 0.2)\">\n <h6 style=\"color:var(--color-warning); margin-bottom:8px; font-weight:600; display:flex; align-items:center; gap:4px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">warning</span> Irreversible Profile Conversions\n </h6>\n <p style=\"font-size:11px; color:var(--text-secondary); margin-bottom:12px; line-height:1.3;\">\n Convert your deployment type. These transitions are permanent and one-way.\n </p>\n ${localDeploymentType === 'single_user' ? `\n <button class=\"btn\" id=\"btn-convert-multiuser\" style=\"width:100%; justify-content:center; margin-bottom:12px; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">lan</span> Convert to Multi-User Local Network Sync\n </button>\n ` : ''}\n <button class=\"btn\" id=\"btn-convert-cloud-action\" style=\"width:100%; justify-content:center; background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px; font-size:12.5px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">cloud_upload</span> Convert to Cloud Sync\n </button>\n </div>\n ` : ''}\n </div>\n ` : '<div style=\"font-size:12.5px; color:var(--text-tertiary)\">Administrative actions are restricted to company administrators.</div>'}\n </div>\n </div>\n </div>\n\n <!-- Local Data Backup -->\n ${!isLocalMode && currentUser.role === 'admin' ? `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary)\">backup</span>\n <h4 style=\"margin:0;\">Local Data Backup</h4>\n </div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:12px;\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm); line-height:1.4; margin:0;\">\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n </p>\n <div id=\"backup-status-container\"></div>\n </div>\n </div>\n ` : ''}\n </div>\n\n <!-- Right Column -->\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">System Color Theme</label>\n <select class=\"form-select\" id=\"system-theme-select\" style=\"width:100%\">\n ${Object.entries(THEMES).map(([key, val]) => `\n <option value=\"${key}\" ${currentTheme === key ? 'selected' : ''}>${val.name}</option>\n `).join('')}\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Select a color profile and visual background effect for your user interface.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', async (e) => {\n const select = e.target;\n select.disabled = true;\n try {\n const settings = store.getSettings();\n settings.tooltipPreference = select.value;\n await store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved successfully', 'success');\n } catch (err) {\n console.error('Error saving tooltip preference:', err);\n showToast('Failed to save preferences: ' + (err.message || err), 'error');\n } finally {\n select.disabled = false;\n }\n });\n\n tc.querySelector('#system-theme-select')?.addEventListener('change', (e) => {\n applyTheme(e.target.value, true);\n showToast('System theme updated successfully', 'success');\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a complete demonstration dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load a complete, realistic demonstration dataset representing an active trade business (5 customers, 5 quotes/jobs per customer, 5 assets per customer, 5 materials, 5 users/timesheets, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for testing and walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed Demonstration Data\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Demo Data\",\n className: \"btn-primary\",\n onClick: async (close) => {\n close();\n showToast('Seeding demonstration data...', 'info');\n await seedMinimalData();\n showToast('Demonstration data seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-delete-company')?.addEventListener('click', () => {\n // Modal Warning 1 of 2\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">warning</span>\n <span>You are about to permanently delete the entire company profile for <strong>${escapeHTML(store.getSettings().name || '')}</strong> and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).</span>\n </div>\n <p style=\"font-size:12.5px; color:var(--text-secondary); margin-bottom:12px\">\n This action is final, irreversible, and cannot be undone under any circumstances.\n </p>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Do you want to proceed to password confirmation?</p>\n `;\n\n showModal({\n title: \"Delete Company Profile - Step 1 of 2\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (closeModal) => closeModal()\n },\n {\n label: \"Proceed to Confirm\",\n className: \"btn-danger\",\n onClick: (closeModal) => {\n closeModal();\n \n // Modal Warning 2 of 2\n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <form id=\"delete-company-confirm-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <p style=\"margin-bottom:8px; font-weight:600; color:var(--color-danger)\">FINAL CONFIRMATION (2 of 2)</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">gavel</span>\n <span>To authorize the permanent destruction of this company, you must enter the Administrator password.</span>\n </div>\n\n <div class=\"form-group\" style=\"margin-top:12px;\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Password</label>\n <input class=\"form-input\" type=\"password\" id=\"delete-confirm-password\" required placeholder=\"Enter admin password to proceed\" />\n </div>\n\n <div id=\"delete-confirm-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"delete-confirm-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-delete-abort\">Abort Deletion</button>\n <button type=\"submit\" class=\"btn btn-danger\" id=\"btn-delete-confirm-submit\" style=\"display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\">delete_forever</span>\n <span>Permanently Delete Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close: close2 } = showModal({\n title: \"⚠️ Confirm Company Deletion - Step 2 of 2\",\n content: content2,\n size: \"modal-md\"\n });\n\n content2.querySelector('#btn-delete-abort').addEventListener('click', close2);\n\n const form2 = content2.querySelector('#delete-company-confirm-form');\n form2.addEventListener('submit', async (ev) => {\n ev.preventDefault();\n\n const errorEl = content2.querySelector('#delete-confirm-error');\n const errorTextEl = content2.querySelector('#delete-confirm-error-text');\n const submitBtn = content2.querySelector('#btn-delete-confirm-submit');\n const abortBtn = content2.querySelector('#btn-delete-abort');\n const passwordInput = content2.querySelector('#delete-confirm-password').value;\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n abortBtn.disabled = true;\n const origSubmitText = submitBtn.innerHTML;\n submitBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Deleting...';\n\n try {\n let passwordVerified = false;\n\n if (isLocalMode) {\n if (localDeploymentType === 'single_user') {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acct = storedAccounts.find(a => a.id === activeAccountId);\n if (acct && acct.hasPassword) {\n const hashedInput = await hashPassword(passwordInput);\n if (hashedInput === acct.passwordHash) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n // No password set\n passwordVerified = true;\n }\n } else {\n // local_multiuser\n const techs = store.getAll('technicians') || [];\n const currentTech = techs.find(t => t.id === currentUser.id);\n const expectedPassword = currentTech ? currentTech.password : '123456';\n if (passwordInput === expectedPassword) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n }\n } else {\n // Cloud mode\n const { supabase } = await import('../utils/supabase.js');\n const { data: { user } } = await supabase.auth.getUser();\n if (user && user.email) {\n const { error } = await supabase.auth.signInWithPassword({\n email: user.email,\n password: passwordInput\n });\n if (!error) {\n passwordVerified = true;\n } else {\n throw new Error('Incorrect administrator password.');\n }\n } else {\n throw new Error('Could not retrieve logged-in cloud user credentials.');\n }\n }\n\n if (passwordVerified) {\n // 1. If cloud mode, delete the company row in Supabase\n if (!isLocalMode) {\n const { supabase } = await import('../utils/supabase.js');\n const { error: deleteErr } = await supabase.from('companies').delete().eq('id', store.companyId);\n if (deleteErr) {\n throw new Error('Failed to delete cloud company: ' + deleteErr.message);\n }\n }\n\n // 2. Trigger store.clearAll()\n store.clearAll();\n\n // 3. Clear local account launcher keys if local mode\n if (isLocalMode) {\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const updatedAccounts = storedAccounts.filter(a => a.id !== activeAccountId);\n await storageSet('relay_accounts', updatedAccounts);\n }\n }\n\n // 4. Remove session user & active account\n localStorage.removeItem('currentUser');\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Company profile deleted successfully.', 'success');\n close2();\n\n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n } catch (err) {\n console.error('Company deletion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during verification.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n abortBtn.disabled = false;\n submitBtn.innerHTML = origSubmitText;\n }\n });\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-convert-multiuser')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n\n const techs = store.getAll('technicians') || [];\n const companyId = store.companyId || '';\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const existingAdmin = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n const currentTechName = existingAdmin ? existingAdmin.name : (currentUser.name && currentUser.name !== 'Local Admin' ? currentUser.name : '');\n const currentTechUsernameOrEmail = existingAdmin ? (existingAdmin.email || existingAdmin.username || '') : '';\n\n content.innerHTML = `\n <form id=\"convert-multiuser-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-danger); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-danger);\">warning</span>\n <div>\n <strong>CRITICAL WARNING:</strong> Converting to Multi-User Local Network Sync is a permanent, one-way transition. Once converted, you cannot revert this profile back to a single-user Local Admin profile.\n </div>\n </div>\n\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Confirm Business Name</label>\n <input class=\"form-input\" id=\"multiuser-company-name\" required value=\"${escapeHTML(store.getSettings().name || '')}\" placeholder=\"e.g. Apex Power Services\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"multiuser-admin-name\" required value=\"${escapeHTML(currentTechName)}\" placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Email Address (Required)</label>\n <input class=\"form-input\" type=\"email\" id=\"multiuser-admin-email\" required value=\"${escapeHTML(currentTechUsernameOrEmail)}\" placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Admin Password</label>\n <input class=\"form-input\" type=\"password\" id=\"multiuser-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"multiuser-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"multiuser-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-multiuser-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-multiuser-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">lan</span>\n <span>Convert Company</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: \"Convert to Multi-User Local Sync\",\n content: content,\n size: \"modal-md\"\n });\n\n content.querySelector('#btn-multiuser-cancel').addEventListener('click', close);\n\n const form = content.querySelector('#convert-multiuser-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n const errorEl = content.querySelector('#multiuser-error');\n const errorTextEl = content.querySelector('#multiuser-error-text');\n const submitBtn = content.querySelector('#btn-multiuser-submit');\n const cancelBtn = content.querySelector('#btn-multiuser-cancel');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n\n const businessName = content.querySelector('#multiuser-company-name').value.trim();\n const adminName = content.querySelector('#multiuser-admin-name').value.trim();\n const adminEmail = content.querySelector('#multiuser-admin-email').value.trim();\n const adminPassword = content.querySelector('#multiuser-admin-password').value;\n\n try {\n if (!businessName || !adminName || !adminEmail || !adminPassword) {\n throw new Error('All fields are required.');\n }\n if (adminPassword.length < 6) {\n throw new Error('Password must be at least 6 characters.');\n }\n\n // 1. Update settings\n const settings = store.getSettings();\n settings.name = businessName;\n settings.localDeploymentType = 'multi_user';\n await store.saveSettings(settings);\n\n // 2. Update launcher accounts businessName\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n if (activeAccountId) {\n const storedAccounts = await storageGet('relay_accounts') || [];\n const acctIndex = storedAccounts.findIndex(a => a.id === activeAccountId);\n if (acctIndex !== -1) {\n storedAccounts[acctIndex].businessName = businessName;\n await storageSet('relay_accounts', storedAccounts);\n }\n }\n\n // 3. Create or update admin user in technicians\n const companyId = store.companyId;\n const adminTypeId = companyId.startsWith('acct_') ? `${companyId}_ut_admin` : 'ut_admin';\n const techs = store.getAll('technicians') || [];\n let adminTech = techs.find(t => t.userTypeId === adminTypeId || t.role?.toLowerCase() === 'admin');\n\n if (!adminTech) {\n adminTech = {\n id: `${companyId}_tech_admin_created`,\n role: 'Admin',\n color: '#FF5C00',\n userTypeId: adminTypeId,\n payRate: 100.00,\n phone: ''\n };\n techs.push(adminTech);\n }\n\n adminTech.name = adminName;\n adminTech.username = adminEmail.split('@')[0];\n adminTech.email = adminEmail;\n adminTech.password = adminPassword;\n\n store.save('technicians', techs);\n\n // 4. Update currentUser session in localStorage\n const localUser = {\n id: adminTech.id,\n companyId: companyId,\n name: adminTech.name,\n role: 'admin',\n userTypeName: 'Admin',\n userTypeId: adminTech.userTypeId,\n color: adminTech.color || '#FF5C00',\n theme: 'light'\n };\n localStorage.setItem('currentUser', JSON.stringify(localUser));\n\n showToast('Converted to Multi-User Local Network Sync. Reloading...', 'success');\n close();\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n setTimeout(() => window.location.reload(), 1200);\n } catch (err) {\n console.error('Conversion failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during conversion.';\n errorEl.style.display = 'flex';\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n }\n });\n });\n\n tc.querySelector('#btn-convert-cloud-action')?.addEventListener('click', () => {\n openMigrationModal();\n });\n\n const renderBackupStatus = () => {\n const bsc = tc.querySelector('#backup-status-container');\n if (!bsc) return;\n\n const hasHandle = !!store.backupDirHandle;\n const isPermissionGranted = store.backupDirPermissionGranted;\n const lastBackup = localStorage.getItem('relay_last_backup_time');\n const formattedLastBackup = lastBackup ? new Date(lastBackup).toLocaleString() : 'Never';\n const isSupported = typeof window !== 'undefined' && window.showDirectoryPicker;\n\n if (!isSupported) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-danger); line-height:1.4;\">\n <strong style=\"display:block; margin-bottom:4px;\">Browser Local Folder Access Unsupported</strong>\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n </div>\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:12px; border-radius:6px; margin-bottom:12px; color:var(--text-secondary); display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary);\">folder_off</span>\n <div style=\"font-size:12.5px;\">No backup folder configured.</div>\n </div>\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">folder</span> Choose Backup Folder...\n </button>\n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-warning); line-height:1.4; margin-bottom:12px;\">\n <strong>Permission Required</strong><br/>\n Access permission to <strong>${escapeHTML(store.backupDirHandle.name)}</strong> has expired. Please re-authorize.\n </div>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-warning\" id=\"btn-backup-auth\" style=\"flex:1; justify-content:center;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize & Backup\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n `;\n } else {\n bsc.innerHTML = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:10px 12px; border-radius:4px; font-size:12px; color:var(--color-success); line-height:1.4; margin-bottom:12px;\">\n <strong>Backup Configured</strong><br/>\n Folder: <strong>${escapeHTML(store.backupDirHandle.name)}</strong><br/>\n Last Backup: <strong>${formattedLastBackup}</strong>\n </div>\n <div style=\"display:flex; flex-direction:column; gap:8px;\">\n <button class=\"btn btn-primary\" id=\"btn-backup-now\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">backup</span> Backup Now\n </button>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-secondary\" id=\"btn-backup-pick\" style=\"flex:1; justify-content:center; border:1px solid var(--border-color);\">\n <span class=\"material-icons-outlined\">folder</span> Change Folder...\n </button>\n <button class=\"btn btn-ghost\" id=\"btn-backup-disconnect\" style=\"color:var(--color-danger); border:1px solid var(--border-color);\" title=\"Disconnect Folder\">\n <span class=\"material-icons-outlined\">link_off</span>\n </button>\n </div>\n </div>\n `;\n }\n\n // Attach event listeners for backup buttons inside container\n bsc.querySelector('#btn-backup-pick')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setBackupDirectory(handle);\n showToast('Backup directory selected successfully.', 'success');\n showToast('Running initial backup...', 'info');\n await store.backupToFolder(handle);\n showToast('Backup completed successfully!', 'success');\n renderBackupStatus();\n } catch (err) {\n console.error('Failed to select backup directory:', err);\n if (err.name !== 'AbortError') {\n showToast('Failed to configure backup: ' + err.message, 'error');\n }\n }\n });\n\n bsc.querySelector('#btn-backup-auth')?.addEventListener('click', async () => {\n const granted = await store.verifyBackupDirPermission(true);\n if (granted) {\n showToast('Permission re-authorized. Backing up...', 'info');\n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n showToast('Backup failed: ' + err.message, 'error');\n }\n } else {\n showToast('Failed to acquire write permission.', 'error');\n }\n renderBackupStatus();\n });\n\n bsc.querySelector('#btn-backup-now')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Backing up...';\n \n try {\n await store.backupToFolder();\n showToast('Backup completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Backup failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n renderBackupStatus();\n }\n });\n\n bsc.querySelector('#btn-backup-disconnect')?.addEventListener('click', async () => {\n await store.setBackupDirectory(null);\n showToast('Backup folder disconnected.', 'info');\n renderBackupStatus();\n });\n };\n\n if (!isLocalMode && currentUser.role === 'admin') {\n renderBackupStatus();\n }\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', username: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Username</label>\n <input class=\"form-input\" id=\"u-username\" value=\"${t.username || (t.email ? t.email.split('@')[0] : '')}\" ${editId ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''} placeholder=\"e.g. joshua\" />\n ${!editId ? `\n <div style=\"font-size: 11px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500;\">\n Company login code is: <strong style=\"color: var(--color-primary)\">${companySlug}</strong>. User will log in with <strong style=\"color: var(--color-primary)\">username@${companySlug}</strong>\n </div>\n ` : ''}\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">${editId ? 'Reset Password (leave blank to keep current)' : 'Temporary Password (assigned for worker login)'}</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"${editId ? '••••••••' : 'Min. 6 characters'}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\" ${(t.userTypeId === 'ut_admin' || (t.userTypeId && t.userTypeId.endsWith('_ut_admin'))) ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''}>\n <option value=\"\">-- Select --</option>\n ${userTypes\n .filter(ut => (!ut.id.endsWith('_ut_admin') && ut.id !== 'ut_admin') || t.userTypeId === ut.id)\n .map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value.trim();\n const username = document.getElementById('u-username').value.trim();\n const role = document.getElementById('u-role').value.trim();\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = document.getElementById('u-password')?.value || '';\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!username) { showToast('Username required', 'error'); return; }\n if (username.includes('@')) { showToast('Username cannot contain @ symbol', 'error'); return; }\n \n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (password && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n const updates = { name, username, role, userTypeId, color, payRate };\n if (password) {\n updates.password = password;\n }\n\n if (editId) {\n await store.update('technicians', editId, updates);\n } else {\n await store.create('technicians', updates);\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = async () => {\n const btn = tc.querySelector('#btn-save-materials');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n await store.saveSettings(updatedSettings);\n showToast('Material settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving material settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const isLocalMode = !store.companyId || store.companyId.startsWith('acct_');\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-00023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-00129',\n originalQuoteNumber: 'Q-00412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' },\n ballet: { name: 'Ballet Pointe', preset: 'ballet', accentColor: '#e58799', headerBg: '#5c2c36', accentTint: '#fff5f6', fontFamily: 'serif' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-company-name\" style=\"width:16px; height:16px\" ${dt.hideCompanyName ? 'checked' : ''} />\n <label for=\"theme-hide-company-name\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide company name text (use logo only)</label>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Variation</label>\n <select class=\"form-select\" id=\"theme-logo-source\" style=\"width:100%\">\n <option value=\"large\" ${dt.logoSource === 'large' ? 'selected' : ''}>Standard Logo (Large)</option>\n <option value=\"small\" ${dt.logoSource === 'small' ? 'selected' : ''}>Small / Shrunk Logo (Small)</option>\n </select>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:8px 0\"></div>\n <div class=\"form-group ${isLocalMode ? 'disabled-local' : ''}\" ${isLocalMode ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''} style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-brand-logo\" style=\"width:16px; height:16px\" ${dt.hideBrandLogo ? 'checked' : ''} ${isLocalMode ? 'disabled' : ''} />\n <label for=\"theme-hide-brand-logo\" class=\"form-label\" style=\"margin:0; cursor:${isLocalMode ? 'not-allowed' : 'pointer'}\">Remove Relay-dispatch brand logo from document footers</label>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Hide Company Name toggle\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) {\n hideCompanyNameChk.addEventListener('change', () => {\n dt.hideCompanyName = hideCompanyNameChk.checked;\n updatePreview();\n });\n }\n\n // Logo Variation toggle\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) {\n logoSourceSelect.addEventListener('change', (e) => {\n dt.logoSource = e.target.value;\n updatePreview();\n });\n }\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Hide Brand Logo toggle\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) {\n hideBrandLogoChk.addEventListener('change', () => {\n dt.hideBrandLogo = hideBrandLogoChk.checked;\n updatePreview();\n });\n }\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-theme-save');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n await store.saveSettings(freshSettings);\n showToast('Document customization settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving theme settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n hideCompanyName: false,\n hideBrandLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) hideCompanyNameChk.checked = !!dt.hideCompanyName;\n\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) logoSourceSelect.value = dt.logoSource || 'large';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) hideBrandLogoChk.checked = !!dt.hideBrandLogo;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n const logoUrl = dt.logoSource === 'small' ? (settings.logoSmall || settings.logo) : (settings.logo || settings.logoSmall);\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.src = logoUrl || '';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // Company Name visibility\n const companyNameEl = frameDoc.querySelector('.pdf-company-name');\n if (companyNameEl) {\n companyNameEl.style.display = dt.hideCompanyName ? 'none' : 'block';\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n\n const footerBrandingEl = frameDoc.querySelector('.pdf-footer-branding');\n if (footerBrandingEl) {\n const hideBrandLogo = isLocalMode ? false : !!dt.hideBrandLogo;\n footerBrandingEl.style.display = hideBrandLogo ? 'none' : 'flex';\n }\n }\n\n renderMarkup();\n }\n\n function renderFolderSyncTab(tc) {\n const isSupported = typeof window !== 'undefined' && (\n (window.indexedDB && window.showDirectoryPicker) ||\n (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem)\n );\n\n const isCapacitor = typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem;\n\n const activeAccountId = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('relay_active_account') : null;\n const isLocalCompany = !!activeAccountId;\n\n function render() {\n console.log('[DEBUG Settings] render: isLocalCompany =', isLocalCompany, 'store.dirHandle =', store.dirHandle, 'permissionGranted =', store.folderSyncPermissionGranted);\n const isEnabled = isLocalCompany ? true : store.folderSyncEnabled;\n const isPermissionGranted = store.folderSyncPermissionGranted;\n const hasHandle = !!store.dirHandle;\n\n let statusHtml = '';\n if (!isSupported) {\n statusHtml = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:16px; border-radius:4px; color:var(--color-danger); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:8px; font-weight:600; margin-bottom:4px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Browser Directory Access Unsupported</span>\n </div>\n <p style=\"font-size:var(--font-size-sm); margin:0; line-height:1.4;\">\n Your current browser does not support local folder access. To enable direct folder synchronization, please run this app in a Chromium-based browser (Chrome, Edge, Opera, or as a compiled native app). High-capacity IndexedDB storage remains active.\n </p>\n </div>\n `;\n } else if (!isEnabled) {\n statusHtml = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:16px; border-radius:6px; color:var(--text-secondary); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px; color:var(--text-tertiary);\">folder_off</span>\n <div>\n <div style=\"font-weight:600; color:var(--text-primary); margin-bottom:2px;\">Folder Synchronization Inactive</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4;\">All data is currently stored in your browser's private IndexedDB database sandbox.</p>\n </div>\n </div>\n `;\n } else if (isCapacitor) {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">cloud_done</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Capacitor Direct Folder Sync Active</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Data is synchronizing directly to the application's native <strong>Documents/RelayDispatchData</strong> directory on your iPad/device.\n </p>\n </div>\n </div>\n `;\n } else if (!hasHandle) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">warning</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">No Directory Selected</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">Please select a directory folder on your computer to begin syncing.</p>\n </div>\n </div>\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:12px; margin-bottom:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">lock</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Access Permission Suspended</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n The browser requires re-authorization to read/write files in <strong>${escapeHTML(store.dirHandle.name)}</strong>.\n </p>\n </div>\n </div>\n <button class=\"btn btn-warning\" id=\"btn-reauthorize-dir\" style=\"margin-left:44px;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize Access\n </button>\n </div>\n `;\n } else {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">check_circle</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Folder Sync Active & Synchronized</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Active Folder: <strong>${escapeHTML(store.dirHandle.name)}</strong>. All data edits are writing dynamically.\n </p>\n </div>\n </div>\n `;\n }\n\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <!-- Folder Configuration -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Direct Directory Sync</h4></div>\n <div class=\"card-body\">\n ${statusHtml}\n\n <div class=\"form-group\" style=\"margin-bottom:var(--space-lg);\">\n <label class=\"form-label\" style=\"font-weight:600; margin-bottom:8px;\">Synchronization Toggle</label>\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\">\n <input type=\"checkbox\" id=\"toggle-folder-sync\" ${isEnabled ? 'checked' : ''} ${(!isSupported || isLocalCompany) ? 'disabled' : ''} style=\"width:20px; height:20px; cursor:${(!isSupported || isLocalCompany) ? 'not-allowed' : 'pointer'};\" />\n <div>\n <span style=\"font-weight:500;\">Enable Direct Local Folder Storage</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">\n ${isLocalCompany ? 'Mandatory for offline/local company profile storage.' : 'Saves all data records and attachments straight to your machine.'}\n </div>\n </div>\n </label>\n </div>\n\n ${isEnabled && isSupported && !isCapacitor ? `\n <div style=\"display:flex; gap:12px; flex-wrap:wrap; border-top:1px solid var(--border-color); padding-top:var(--space-lg);\">\n <button class=\"btn btn-secondary\" id=\"btn-pick-dir\">\n <span class=\"material-icons-outlined\">folder</span> Choose Sync Folder...\n </button>\n ${hasHandle && isPermissionGranted ? `\n <button class=\"btn btn-secondary\" id=\"btn-force-sync\" data-tooltip=\"Overwrite folder JSONs with current memory cache\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">sync</span> Sync Now\n </button>\n ${!isLocalCompany ? `\n <button class=\"btn btn-danger\" id=\"btn-disconnect-dir\">\n <span class=\"material-icons-outlined\">link_off</span> Disconnect Folder\n </button>\n ` : ''}\n ` : ''}\n </div>\n ` : ''}\n </div>\n </div>\n\n <!-- Instructions Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>How Self-Hosting Works</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p>\n By linking a local folder, you establish a <strong>serverless self-hosted data hub</strong>.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">storage</span>\n <span><strong>Readable Data:</strong> Your jobs, quotes, and company configurations are saved in <code>data/*.json</code> files.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">photo_library</span>\n <span><strong>Physical Attachments:</strong> Uploaded photos and PDF catalogs are saved as standard image/PDF files under the <code>documents/</code> subfolder.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">sync_alt</span>\n <span><strong>Cloud Backups:</strong> Select a folder synced to <strong>OneDrive, Google Drive, or Dropbox</strong> to automatically back up and sync your data to the cloud.</span>\n </div>\n </div>\n </div>\n </div>\n `;\n\n // Event Listeners\n const toggle = tc.querySelector('#toggle-folder-sync');\n toggle?.addEventListener('change', async (e) => {\n if (isLocalCompany) {\n e.preventDefault();\n toggle.checked = true;\n return;\n }\n\n if (isCapacitor) {\n await store.setLocalDirectory(e.target.checked ? {} : null);\n showToast(e.target.checked ? 'Capacitor folder sync activated' : 'Folder sync deactivated', 'success');\n render();\n return;\n }\n\n if (e.target.checked) {\n // Trigger directory picker\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory configured successfully', 'success');\n } catch (err) {\n console.error('Directory selection cancelled or failed:', err);\n toggle.checked = false;\n showToast('Folder selection cancelled', 'info');\n }\n } else {\n await store.setLocalDirectory(null);\n showToast('Direct directory synchronization disabled', 'info');\n }\n render();\n });\n\n tc.querySelector('#btn-pick-dir')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory updated successfully', 'success');\n render();\n } catch (err) {\n console.error(err);\n }\n });\n\n tc.querySelector('#btn-reauthorize-dir')?.addEventListener('click', async () => {\n const granted = await store.verifyDirPermission(true);\n if (granted) {\n showToast('Folder access re-authorized successfully', 'success');\n } else {\n showToast('Failed to acquire write permissions', 'error');\n }\n render();\n });\n\n tc.querySelector('#btn-disconnect-dir')?.addEventListener('click', async () => {\n await store.setLocalDirectory(null);\n showToast('Local folder disconnected', 'info');\n render();\n });\n\n tc.querySelector('#btn-force-sync')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n btn.disabled = true;\n showToast('Writing cache data to local directory...', 'info');\n try {\n const collections = Object.keys(store.cache);\n await Promise.all(collections.map(col => store.writeCollectionToFolder(col, store.cache[col])));\n showToast('Folder sync completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Folder sync failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n }\n });\n }\n\n render();\n }\n\n\n\n"}, "3875d9aed63c32df22c0dea6475d5f31122475ef": {"t": 1785754191.3129747, "n": 203680, "h": "2d2a1f18c734eff4", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\nimport { applyTheme, THEMES } from '../utils/theme.js';\n\n// Compress uploaded images using Canvas to avoid huge Base64 data payloads\nfunction compressImage(dataUrl, maxWidth, maxHeight) {\n return new Promise((resolve) => {\n if (!dataUrl) {\n resolve(dataUrl);\n return;\n }\n // SVG vectors are already lightweight; do not compress them\n if (dataUrl.startsWith('data:image/svg+xml')) {\n resolve(dataUrl);\n return;\n }\n const img = new Image();\n img.onload = () => {\n let width = img.width;\n let height = img.height;\n\n if (width > height) {\n if (width > maxWidth) {\n height = Math.round((height * maxWidth) / width);\n width = maxWidth;\n }\n } else {\n if (height > maxHeight) {\n width = Math.round((width * maxHeight) / height);\n height = maxHeight;\n }\n }\n\n const canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n\n const ctx = canvas.getContext('2d');\n ctx.drawImage(img, 0, 0, width, height);\n\n const isPng = dataUrl.includes('image/png');\n const format = isPng ? 'image/png' : 'image/jpeg';\n const quality = isPng ? undefined : 0.85;\n\n resolve(canvas.toDataURL(format, quality));\n };\n img.onerror = () => {\n resolve(dataUrl);\n };\n img.src = dataUrl;\n });\n}\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const isLocalMode = !store.companyId;\n if (isLocalMode && (activeTab === 'users' || activeTab === 'portal')) {\n activeTab = 'company';\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''} ${isLocalMode ? 'disabled-local' : ''}\" data-tab=\"users\" ${isLocalMode ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''}>Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''} ${isLocalMode ? 'disabled-local' : ''}\" data-tab=\"portal\" ${isLocalMode ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''}>Customer Portal</button>\n <button class=\"tab ${activeTab === 'invoices_quotes' ? 'active' : ''}\" data-tab=\"invoices_quotes\">Quotes &amp; Invoices</button>\n <button class=\"tab ${activeTab === 'folder_sync' ? 'active' : ''}\" data-tab=\"folder_sync\">Folder Sync</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n if (tab.classList.contains('disabled-local')) return;\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'folder_sync') {\n renderFolderSyncTab(tc);\n return;\n }\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n \n const openMigrationModal = () => {\n const modalContent = document.createElement('div');\n modalContent.innerHTML = `\n <form id=\"convert-cloud-form\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; border-radius:4px; font-size:12.5px; color:var(--color-info); display:flex; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-info);\">info</span>\n <div>\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Full Name</label>\n <input class=\"form-input\" id=\"migrate-admin-name\" required placeholder=\"e.g. John Doe\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Administrator Phone Number</label>\n <input class=\"form-input\" id=\"migrate-admin-phone\" required placeholder=\"e.g. 0412 345 678\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Email Address (Username)</label>\n <input class=\"form-input\" type=\"email\" id=\"migrate-admin-email\" required placeholder=\"e.g. admin@yourcompany.com\" />\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Password</label>\n <input class=\"form-input\" type=\"password\" id=\"migrate-admin-password\" required minlength=\"6\" placeholder=\"At least 6 characters\" />\n </div>\n\n <div id=\"migration-error\" style=\"display:none; color:var(--color-danger); background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:10px 14px; border-radius:4px; font-size:13px; font-weight:500; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">error_outline</span>\n <span id=\"migration-error-text\"></span>\n </div>\n\n <div style=\"display:flex; justify-content:flex-end; gap:12px; margin-top:8px;\">\n <button type=\"button\" class=\"btn btn-secondary\" id=\"btn-migrate-cancel\">Cancel</button>\n <button type=\"submit\" class=\"btn btn-primary\" id=\"btn-migrate-submit\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" id=\"submit-icon\" style=\"font-size:18px;\">cloud_done</span>\n <span id=\"submit-text\">Register & Start Migration</span>\n </button>\n </div>\n </form>\n `;\n\n const { close } = showModal({\n title: 'Register & Migrate to Cloud',\n content: modalContent,\n size: 'modal-md'\n });\n\n modalContent.querySelector('#btn-migrate-cancel').addEventListener('click', close);\n\n const form = modalContent.querySelector('#convert-cloud-form');\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n\n const errorEl = modalContent.querySelector('#migration-error');\n const errorTextEl = modalContent.querySelector('#migration-error-text');\n const submitBtn = modalContent.querySelector('#btn-migrate-submit');\n const cancelBtn = modalContent.querySelector('#btn-migrate-cancel');\n const submitText = modalContent.querySelector('#submit-text');\n const submitIcon = modalContent.querySelector('#submit-icon');\n\n errorEl.style.display = 'none';\n submitBtn.disabled = true;\n cancelBtn.disabled = true;\n submitText.textContent = 'Migrating to Cloud...';\n submitIcon.className = 'material-icons-outlined spinner';\n submitIcon.textContent = 'sync';\n submitIcon.style.animation = 'spin 1s linear infinite';\n\n const adminName = modalContent.querySelector('#migrate-admin-name').value.trim();\n const adminPhone = modalContent.querySelector('#migrate-admin-phone').value.trim();\n const email = modalContent.querySelector('#migrate-admin-email').value.trim();\n const password = modalContent.querySelector('#migrate-admin-password').value;\n const companyName = store.getSettings().name || 'My Local Business';\n\n try {\n // Import supabase dynamically to avoid issues\n const { supabase } = await import('../utils/supabase.js');\n\n // 1. Sign up the user in Supabase Auth\n const { data: authData, error: authErr } = await supabase.auth.signUp({\n email,\n password,\n options: {\n data: {\n name: adminName,\n phone: adminPhone\n }\n }\n });\n if (authErr) throw authErr;\n\n if (!authData.user) {\n throw new Error('Verification required or signup was blocked. Check your email inbox.');\n }\n\n // 2. Call the security definer RPC function to create company and profile records\n const { data: companyId, error: rpcError } = await supabase.rpc('create_company_and_admin', {\n user_id: authData.user.id,\n company_name: companyName,\n admin_name: adminName,\n admin_phone: adminPhone\n });\n if (rpcError) throw rpcError;\n\n // 3. Trigger local to cloud data migration\n const activeAccountId = sessionStorage.getItem('relay_active_account');\n await store.migrateLocalToCloud(companyId, authData.user.id);\n\n // 4. Remove the local profile from the account list\n if (activeAccountId) {\n const localAccountsKey = 'relay_accounts';\n let localAccounts = [];\n try {\n const stored = localStorage.getItem(localAccountsKey);\n if (stored) {\n localAccounts = JSON.parse(stored);\n }\n } catch (e) {\n console.error('Error reading local accounts:', e);\n }\n localAccounts = localAccounts.filter(a => a.id !== activeAccountId);\n localStorage.setItem(localAccountsKey, JSON.stringify(localAccounts));\n\n // 5. Delete local namespaced data\n store.deleteLocalAccountData(activeAccountId);\n }\n\n // 6. Fetch the newly created profile to construct currentUser session object\n const { data: profile, error: profileErr } = await supabase\n .from('profiles')\n .select('*')\n .eq('id', authData.user.id)\n .single();\n if (profileErr) throw profileErr;\n\n const user = {\n id: profile.id,\n companyId: profile.company_id,\n name: profile.name,\n role: profile.role,\n userTypeName: 'Admin',\n userTypeId: `${profile.company_id}_ut_admin`,\n color: profile.color || '#FF5C00',\n theme: 'light'\n };\n\n localStorage.setItem('currentUser', JSON.stringify(user));\n sessionStorage.removeItem('relay_active_account');\n\n showToast('Migration completed successfully! Redirecting...', 'success');\n close();\n\n // 7. Reload application shell to boot into cloud mode\n setTimeout(() => {\n window.location.hash = '#/';\n window.location.reload();\n }, 1500);\n\n } catch (err) {\n console.error('Migration failed:', err);\n errorTextEl.textContent = err.message || 'An error occurred during migration.';\n errorEl.style.display = 'flex';\n\n submitBtn.disabled = false;\n cancelBtn.disabled = false;\n submitText.textContent = 'Register & Start Migration';\n submitIcon.className = 'material-icons-outlined';\n submitIcon.textContent = 'cloud_done';\n submitIcon.style.animation = '';\n }\n });\n };\n\n const renderCompanyTab = () => {\n let convertToCloudCard = '';\n if (store.companyId && store.companyId.startsWith('acct_')) {\n convertToCloudCard = `\n <div class=\"card\" style=\"max-width:850px; margin-top:24px; border:1px solid var(--color-warning);\">\n <div class=\"card-header\" style=\"background:var(--color-warning-bg); color:var(--color-warning); display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">cloud_upload</span>\n <h4 style=\"color:var(--color-warning); margin:0;\">Convert to Cloud Account</h4>\n </div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <p style=\"font-size:13.5px; line-height:1.6; color:var(--text-secondary); margin:0;\">\n This business profile is currently running locally. You can migrate this profile and all its offline data (customers, jobs, quotes, settings, etc.) to the Antigravity Cloud, creating a secure online account.\n </p>\n <div style=\"background:var(--bg-color); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px;\">\n <strong style=\"display:block; margin-bottom:8px; font-size:13px; color:var(--text-primary);\">Conversion Process Highlights:</strong>\n <ul style=\"margin:0; padding-left:20px; font-size:12.5px; color:var(--text-secondary); display:flex; flex-direction:column; gap:6px;\">\n <li>Registers a new administrator account in Supabase.</li>\n <li>Safely uploads all local offline data to the cloud databases.</li>\n <li>Wipes namespaced local files and IndexedDB databases from this device.</li>\n <li>Transitions your session seamlessly to the cloud company dashboard.</li>\n </ul>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-convert-cloud\" style=\"background:var(--color-warning); border-color:var(--color-warning); color:#fff; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px;\">cloud_upload</span>\n <span>Register & Migrate to Cloud</span>\n </button>\n </div>\n </div>\n `;\n }\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n ${convertToCloudCard}\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Standard Logo (Large) using Canvas to ~600x300 max bounding box\n compressImage(re.target.result, 600, 300).then((compressed) => {\n pendingLogo = compressed;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Small/Shrunk Logo using Canvas to ~200x100 max bounding box\n compressImage(re.target.result, 200, 100).then((compressed) => {\n pendingLogoSmall = compressed;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n const convertBtn = tc.querySelector('#btn-convert-cloud');\n if (convertBtn) {\n convertBtn.addEventListener('click', () => {\n openMigrationModal();\n });\n }\n\n tc.querySelector('#btn-save-company').addEventListener('click', async () => {\n const saveBtn = tc.querySelector('#btn-save-company');\n const originalHtml = saveBtn.innerHTML;\n saveBtn.disabled = true;\n saveBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving Changes...';\n\n try {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n await store.saveSettings(settings);\n showToast('Company information saved successfully to database', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n } catch (err) {\n console.error('Error saving company profile settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n saveBtn.disabled = false;\n saveBtn.innerHTML = originalHtml;\n }\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div style=\"background:rgba(59, 130, 246, 0.1); border-left:4px solid #3b82f6; padding:12px 16px; margin-bottom:var(--space-md); border-radius:4px; font-size:13px; color:#f8fafc; display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:#3b82f6; font-size:18px;\">info</span>\n <span>Your company login code is <strong style=\"color:#60a5fa\">${companySlug}</strong>. Technicians log in using <strong style=\"color:#60a5fa\">username@${companySlug}</strong>.</span>\n </span>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm ${!store.companyId ? 'disabled-local' : ''}\" id=\"btn-add-user\" data-tooltip=\"${!store.companyId ? 'Requires Cloud Account' : 'Create a new user account'}\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Username</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n ${ut?.id !== 'ut_admin' ? `\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n ` : ''}\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => {\n if (!store.companyId) return;\n openUserModal();\n });\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax Rates</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\" style=\"display:flex; flex-direction:column; gap:8px\">\n <label class=\"form-label\" style=\"display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:0\">\n <input type=\"checkbox\" id=\"tax-enabled\" style=\"width:16px; height:16px; margin:0\" ${settings.taxEnabled !== false ? 'checked' : ''} />\n Enable GST / Sales Tax\n </label>\n <div style=\"display:${settings.taxEnabled !== false ? 'flex' : 'none'}; align-items:center; gap:8px; margin-top:4px\" id=\"tax-rate-container\">\n <input class=\"form-input\" id=\"tax-rate\" type=\"number\" value=\"${settings.taxRate !== undefined ? settings.taxRate : 10}\" style=\"width:100px\" min=\"0\" max=\"100\" step=\"0.1\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // Toggle tax rate container visibility\n tc.querySelector('#tax-enabled')?.addEventListener('change', (e) => {\n const container = tc.querySelector('#tax-rate-container');\n if (container) {\n container.style.display = e.target.checked ? 'flex' : 'none';\n }\n });\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#save-tax-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const taxEnabled = tc.querySelector('#tax-enabled').checked;\n const taxRate = parseFloat(tc.querySelector('#tax-rate').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.taxEnabled = taxEnabled;\n settings.taxRate = taxRate;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n await store.saveSettings(settings);\n showToast('Financial and Rate settings saved successfully', 'success');\n renderContent();\n } catch (err) {\n console.error('Error saving tax and rate settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-portal-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n await store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving customer portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'system') {\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');\n const currentTheme = (currentUser && currentUser.id) ? (currentUser.theme || localStorage.getItem(`simpro_theme_${currentUser.id}`) || 'light') : 'light';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\" data-tooltip=\"Restore default sandbox demo database\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\" data-tooltip=\"Permanently delete all local database records\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n ${currentUser.role === 'admin' ? `\n <hr style=\"margin:var(--space-md) 0; border:none; border-top:1px dashed var(--border-color);\" />\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups or seed a single test sample to explore the blank app layout.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed exactly one sample record of each entity\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed One Example Version\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n </div>\n ` : ''}\n </div>\n </div>\n </div>\n\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">System Color Theme</label>\n <select class=\"form-select\" id=\"system-theme-select\" style=\"width:100%\">\n ${Object.entries(THEMES).map(([key, val]) => `\n <option value=\"${key}\" ${currentTheme === key ? 'selected' : ''}>${val.name}</option>\n `).join('')}\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Select a color profile and visual background effect for your user interface.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', async (e) => {\n const select = e.target;\n select.disabled = true;\n try {\n const settings = store.getSettings();\n settings.tooltipPreference = select.value;\n await store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved successfully', 'success');\n } catch (err) {\n console.error('Error saving tooltip preference:', err);\n showToast('Failed to save preferences: ' + (err.message || err), 'error');\n } finally {\n select.disabled = false;\n }\n });\n\n tc.querySelector('#system-theme-select')?.addEventListener('change', (e) => {\n applyTheme(e.target.value, true);\n showToast('System theme updated successfully', 'success');\n });\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a minimal example dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load <strong>exactly one example</strong> of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for quick feature walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed One Example Version\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Example\",\n className: \"btn-primary\",\n onClick: (close) => {\n close();\n seedMinimalData();\n showToast('Single-item example seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', username: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Username</label>\n <input class=\"form-input\" id=\"u-username\" value=\"${t.username || (t.email ? t.email.split('@')[0] : '')}\" ${editId ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''} placeholder=\"e.g. joshua\" />\n ${!editId ? `\n <div style=\"font-size: 11px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500;\">\n Company login code is: <strong style=\"color: var(--color-primary)\">${companySlug}</strong>. User will log in with <strong style=\"color: var(--color-primary)\">username@${companySlug}</strong>\n </div>\n ` : ''}\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">${editId ? 'Reset Password (leave blank to keep current)' : 'Temporary Password (assigned for worker login)'}</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"${editId ? '••••••••' : 'Min. 6 characters'}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\" ${(t.userTypeId === 'ut_admin' || (t.userTypeId && t.userTypeId.endsWith('_ut_admin'))) ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''}>\n <option value=\"\">-- Select --</option>\n ${userTypes\n .filter(ut => (!ut.id.endsWith('_ut_admin') && ut.id !== 'ut_admin') || t.userTypeId === ut.id)\n .map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value.trim();\n const username = document.getElementById('u-username').value.trim();\n const role = document.getElementById('u-role').value.trim();\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = document.getElementById('u-password')?.value || '';\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!username) { showToast('Username required', 'error'); return; }\n if (username.includes('@')) { showToast('Username cannot contain @ symbol', 'error'); return; }\n \n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (password && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n const updates = { name, username, role, userTypeId, color, payRate };\n if (password) {\n updates.password = password;\n }\n\n if (editId) {\n await store.update('technicians', editId, updates);\n } else {\n await store.create('technicians', updates);\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = async () => {\n const btn = tc.querySelector('#btn-save-materials');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n await store.saveSettings(updatedSettings);\n showToast('Material settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving material settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const isLocalMode = !store.companyId;\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-10023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-88129',\n originalQuoteNumber: 'Q-54412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' },\n ballet: { name: 'Ballet Pointe', preset: 'ballet', accentColor: '#e58799', headerBg: '#5c2c36', accentTint: '#fff5f6', fontFamily: 'serif' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-company-name\" style=\"width:16px; height:16px\" ${dt.hideCompanyName ? 'checked' : ''} />\n <label for=\"theme-hide-company-name\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide company name text (use logo only)</label>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Variation</label>\n <select class=\"form-select\" id=\"theme-logo-source\" style=\"width:100%\">\n <option value=\"large\" ${dt.logoSource === 'large' ? 'selected' : ''}>Standard Logo (Large)</option>\n <option value=\"small\" ${dt.logoSource === 'small' ? 'selected' : ''}>Small / Shrunk Logo (Small)</option>\n </select>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:8px 0\"></div>\n <div class=\"form-group ${isLocalMode ? 'disabled-local' : ''}\" ${isLocalMode ? 'data-tooltip=\"Requires Cloud Account\" data-tooltip-pos=\"top\"' : ''} style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-brand-logo\" style=\"width:16px; height:16px\" ${dt.hideBrandLogo ? 'checked' : ''} ${isLocalMode ? 'disabled' : ''} />\n <label for=\"theme-hide-brand-logo\" class=\"form-label\" style=\"margin:0; cursor:${isLocalMode ? 'not-allowed' : 'pointer'}\">Remove Relay-dispatch brand logo from document footers</label>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Hide Company Name toggle\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) {\n hideCompanyNameChk.addEventListener('change', () => {\n dt.hideCompanyName = hideCompanyNameChk.checked;\n updatePreview();\n });\n }\n\n // Logo Variation toggle\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) {\n logoSourceSelect.addEventListener('change', (e) => {\n dt.logoSource = e.target.value;\n updatePreview();\n });\n }\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Hide Brand Logo toggle\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) {\n hideBrandLogoChk.addEventListener('change', () => {\n dt.hideBrandLogo = hideBrandLogoChk.checked;\n updatePreview();\n });\n }\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-theme-save');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n await store.saveSettings(freshSettings);\n showToast('Document customization settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving theme settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n hideCompanyName: false,\n hideBrandLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) hideCompanyNameChk.checked = !!dt.hideCompanyName;\n\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) logoSourceSelect.value = dt.logoSource || 'large';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n\n const hideBrandLogoChk = tc.querySelector('#theme-hide-brand-logo');\n if (hideBrandLogoChk) hideBrandLogoChk.checked = !!dt.hideBrandLogo;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n const logoUrl = dt.logoSource === 'small' ? (settings.logoSmall || settings.logo) : (settings.logo || settings.logoSmall);\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.src = logoUrl || '';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // Company Name visibility\n const companyNameEl = frameDoc.querySelector('.pdf-company-name');\n if (companyNameEl) {\n companyNameEl.style.display = dt.hideCompanyName ? 'none' : 'block';\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n\n const footerBrandingEl = frameDoc.querySelector('.pdf-footer-branding');\n if (footerBrandingEl) {\n const hideBrandLogo = isLocalMode ? false : !!dt.hideBrandLogo;\n footerBrandingEl.style.display = hideBrandLogo ? 'none' : 'flex';\n }\n }\n\n renderMarkup();\n }\n\n function renderFolderSyncTab(tc) {\n const isSupported = typeof window !== 'undefined' && (\n (window.indexedDB && window.showDirectoryPicker) ||\n (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem)\n );\n\n const isCapacitor = typeof window !== 'undefined' && window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem;\n\n function render() {\n const isEnabled = store.folderSyncEnabled;\n const isPermissionGranted = store.folderSyncPermissionGranted;\n const hasHandle = !!store.dirHandle;\n\n let statusHtml = '';\n if (!isSupported) {\n statusHtml = `\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:16px; border-radius:4px; color:var(--color-danger); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:8px; font-weight:600; margin-bottom:4px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Browser Directory Access Unsupported</span>\n </div>\n <p style=\"font-size:var(--font-size-sm); margin:0; line-height:1.4;\">\n Your current browser does not support local folder access. To enable direct folder synchronization, please run this app in a Chromium-based browser (Chrome, Edge, Opera, or as a compiled native app). High-capacity IndexedDB storage remains active.\n </p>\n </div>\n `;\n } else if (!isEnabled) {\n statusHtml = `\n <div style=\"background:var(--bg-color); border:1px solid var(--border-color); padding:16px; border-radius:6px; color:var(--text-secondary); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px; color:var(--text-tertiary);\">folder_off</span>\n <div>\n <div style=\"font-weight:600; color:var(--text-primary); margin-bottom:2px;\">Folder Synchronization Inactive</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4;\">All data is currently stored in your browser's private IndexedDB database sandbox.</p>\n </div>\n </div>\n `;\n } else if (isCapacitor) {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">cloud_done</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Capacitor Direct Folder Sync Active</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Data is synchronizing directly to the application's native <strong>Documents/RelayDispatchData</strong> directory on your iPad/device.\n </p>\n </div>\n </div>\n `;\n } else if (!hasHandle) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">warning</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">No Directory Selected</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">Please select a directory folder on your computer to begin syncing.</p>\n </div>\n </div>\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:16px; border-radius:4px; color:var(--color-warning); margin-bottom:var(--space-lg);\">\n <div style=\"display:flex; align-items:center; gap:12px; margin-bottom:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">lock</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Access Permission Suspended</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n The browser requires re-authorization to read/write files in <strong>${escapeHTML(store.dirHandle.name)}</strong>.\n </p>\n </div>\n </div>\n <button class=\"btn btn-warning\" id=\"btn-reauthorize-dir\" style=\"margin-left:44px;\">\n <span class=\"material-icons-outlined\">vpn_key</span> Re-authorize Access\n </button>\n </div>\n `;\n } else {\n statusHtml = `\n <div style=\"background:var(--color-success-bg); border-left:4px solid var(--color-success); padding:16px; border-radius:4px; color:var(--color-success); margin-bottom:var(--space-lg); display:flex; align-items:center; gap:12px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:32px;\">check_circle</span>\n <div>\n <div style=\"font-weight:600; margin-bottom:2px;\">Folder Sync Active & Synchronized</div>\n <p style=\"font-size:12.5px; margin:0; line-height:1.4; color:var(--text-secondary);\">\n Active Folder: <strong>${escapeHTML(store.dirHandle.name)}</strong>. All data edits are writing dynamically.\n </p>\n </div>\n </div>\n `;\n }\n\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 340px; gap:var(--space-lg); max-width:1100px; align-items:start;\">\n <!-- Folder Configuration -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Direct Directory Sync</h4></div>\n <div class=\"card-body\">\n ${statusHtml}\n\n <div class=\"form-group\" style=\"margin-bottom:var(--space-lg);\">\n <label class=\"form-label\" style=\"font-weight:600; margin-bottom:8px;\">Synchronization Toggle</label>\n <label class=\"switch-container\" style=\"display:flex; align-items:center; gap:12px; cursor:pointer;\">\n <input type=\"checkbox\" id=\"toggle-folder-sync\" ${isEnabled ? 'checked' : ''} ${!isSupported ? 'disabled' : ''} style=\"width:20px; height:20px; cursor:pointer;\" />\n <div>\n <span style=\"font-weight:500;\">Enable Direct Local Folder Storage</span>\n <div class=\"text-tertiary\" style=\"font-size:12px; margin-top:2px;\">Saves all data records and attachments straight to your machine.</div>\n </div>\n </label>\n </div>\n\n ${isEnabled && isSupported && !isCapacitor ? `\n <div style=\"display:flex; gap:12px; flex-wrap:wrap; border-top:1px solid var(--border-color); padding-top:var(--space-lg);\">\n <button class=\"btn btn-secondary\" id=\"btn-pick-dir\">\n <span class=\"material-icons-outlined\">folder</span> Choose Sync Folder...\n </button>\n ${hasHandle && isPermissionGranted ? `\n <button class=\"btn btn-secondary\" id=\"btn-force-sync\" data-tooltip=\"Overwrite folder JSONs with current memory cache\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">sync</span> Sync Now\n </button>\n <button class=\"btn btn-danger\" id=\"btn-disconnect-dir\">\n <span class=\"material-icons-outlined\">link_off</span> Disconnect Folder\n </button>\n ` : ''}\n </div>\n ` : ''}\n </div>\n </div>\n\n <!-- Instructions Card -->\n <div class=\"card\" style=\"background:var(--content-bg);\">\n <div class=\"card-header\"><h4>How Self-Hosting Works</h4></div>\n <div class=\"card-body\" style=\"font-size:13px; line-height:1.6; display:flex; flex-direction:column; gap:12px; color:var(--text-secondary);\">\n <p>\n By linking a local folder, you establish a <strong>serverless self-hosted data hub</strong>.\n </p>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">storage</span>\n <span><strong>Readable Data:</strong> Your jobs, quotes, and company configurations are saved in <code>data/*.json</code> files.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">photo_library</span>\n <span><strong>Physical Attachments:</strong> Uploaded photos and PDF catalogs are saved as standard image/PDF files under the <code>documents/</code> subfolder.</span>\n </div>\n <div style=\"display:flex; gap:8px; align-items:flex-start;\">\n <span class=\"material-icons-outlined\" style=\"color:var(--color-primary); font-size:18px; margin-top:2px;\">sync_alt</span>\n <span><strong>Cloud Backups:</strong> Select a folder synced to <strong>OneDrive, Google Drive, or Dropbox</strong> to automatically back up and sync your data to the cloud.</span>\n </div>\n </div>\n </div>\n </div>\n `;\n\n // Event Listeners\n const toggle = tc.querySelector('#toggle-folder-sync');\n toggle?.addEventListener('change', async (e) => {\n if (isCapacitor) {\n await store.setLocalDirectory(e.target.checked ? {} : null);\n showToast(e.target.checked ? 'Capacitor folder sync activated' : 'Folder sync deactivated', 'success');\n render();\n return;\n }\n\n if (e.target.checked) {\n // Trigger directory picker\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory configured successfully', 'success');\n } catch (err) {\n console.error('Directory selection cancelled or failed:', err);\n toggle.checked = false;\n showToast('Folder selection cancelled', 'info');\n }\n } else {\n await store.setLocalDirectory(null);\n showToast('Direct directory synchronization disabled', 'info');\n }\n render();\n });\n\n tc.querySelector('#btn-pick-dir')?.addEventListener('click', async () => {\n try {\n const handle = await window.showDirectoryPicker({ mode: 'readwrite' });\n await store.setLocalDirectory(handle);\n showToast('Sync directory updated successfully', 'success');\n render();\n } catch (err) {\n console.error(err);\n }\n });\n\n tc.querySelector('#btn-reauthorize-dir')?.addEventListener('click', async () => {\n const granted = await store.verifyDirPermission(true);\n if (granted) {\n showToast('Folder access re-authorized successfully', 'success');\n } else {\n showToast('Failed to acquire write permissions', 'error');\n }\n render();\n });\n\n tc.querySelector('#btn-disconnect-dir')?.addEventListener('click', async () => {\n await store.setLocalDirectory(null);\n showToast('Local folder disconnected', 'info');\n render();\n });\n\n tc.querySelector('#btn-force-sync')?.addEventListener('click', async (e) => {\n const btn = e.currentTarget;\n btn.disabled = true;\n showToast('Writing cache data to local directory...', 'info');\n try {\n const collections = Object.keys(store.cache);\n await Promise.all(collections.map(col => store.writeCollectionToFolder(col, store.cache[col])));\n showToast('Folder sync completed successfully!', 'success');\n } catch (err) {\n console.error(err);\n showToast('Folder sync failed: ' + err.message, 'error');\n } finally {\n btn.disabled = false;\n }\n });\n }\n\n render();\n }\n\n\n\n"}, "0cedec2631a6e0e708b088f88e76f55c12a93280": {"t": 1785754193.348904, "n": 178181, "h": "95a81073dc95ae45", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\nimport { applyTheme, THEMES } from '../utils/theme.js';\n\n// Compress uploaded images using Canvas to avoid huge Base64 data payloads\nfunction compressImage(dataUrl, maxWidth, maxHeight) {\n return new Promise((resolve) => {\n if (!dataUrl) {\n resolve(dataUrl);\n return;\n }\n // SVG vectors are already lightweight; do not compress them\n if (dataUrl.startsWith('data:image/svg+xml')) {\n resolve(dataUrl);\n return;\n }\n const img = new Image();\n img.onload = () => {\n let width = img.width;\n let height = img.height;\n\n if (width > height) {\n if (width > maxWidth) {\n height = Math.round((height * maxWidth) / width);\n width = maxWidth;\n }\n } else {\n if (height > maxHeight) {\n width = Math.round((width * maxHeight) / height);\n height = maxHeight;\n }\n }\n\n const canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n\n const ctx = canvas.getContext('2d');\n ctx.drawImage(img, 0, 0, width, height);\n\n const isPng = dataUrl.includes('image/png');\n const format = isPng ? 'image/png' : 'image/jpeg';\n const quality = isPng ? undefined : 0.85;\n\n resolve(canvas.toDataURL(format, quality));\n };\n img.onerror = () => {\n resolve(dataUrl);\n };\n img.src = dataUrl;\n });\n}\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''}\" data-tab=\"portal\">Customer Portal</button>\n <button class=\"tab ${activeTab === 'invoices_quotes' ? 'active' : ''}\" data-tab=\"invoices_quotes\">Quotes &amp; Invoices</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Standard Logo (Large) using Canvas to ~600x300 max bounding box\n compressImage(re.target.result, 600, 300).then((compressed) => {\n pendingLogo = compressed;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Small/Shrunk Logo using Canvas to ~200x100 max bounding box\n compressImage(re.target.result, 200, 100).then((compressed) => {\n pendingLogoSmall = compressed;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n tc.querySelector('#btn-save-company').addEventListener('click', async () => {\n const saveBtn = tc.querySelector('#btn-save-company');\n const originalHtml = saveBtn.innerHTML;\n saveBtn.disabled = true;\n saveBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving Changes...';\n\n try {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n await store.saveSettings(settings);\n showToast('Company information saved successfully to cloud database', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n } catch (err) {\n console.error('Error saving company profile settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n saveBtn.disabled = false;\n saveBtn.innerHTML = originalHtml;\n }\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div style=\"background:rgba(59, 130, 246, 0.1); border-left:4px solid #3b82f6; padding:12px 16px; margin-bottom:var(--space-md); border-radius:4px; font-size:13px; color:#f8fafc; display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:#3b82f6; font-size:18px;\">info</span>\n <span>Your company login code is <strong style=\"color:#60a5fa\">${companySlug}</strong>. Technicians log in using <strong style=\"color:#60a5fa\">username@${companySlug}</strong>.</span>\n </span>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Username</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n ${ut?.id !== 'ut_admin' ? `\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n ` : ''}\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\" style=\"display:flex; flex-direction:column; gap:8px\">\n <label class=\"form-label\" style=\"display:flex; align-items:center; gap:8px; cursor:pointer; margin-bottom:0\">\n <input type=\"checkbox\" id=\"tax-enabled\" style=\"width:16px; height:16px; margin:0\" ${settings.taxEnabled !== false ? 'checked' : ''} />\n Enable GST / Sales Tax\n </label>\n <div style=\"display:${settings.taxEnabled !== false ? 'flex' : 'none'}; align-items:center; gap:8px; margin-top:4px\" id=\"tax-rate-container\">\n <input class=\"form-input\" id=\"tax-rate\" type=\"number\" value=\"${settings.taxRate !== undefined ? settings.taxRate : 10}\" style=\"width:100px\" min=\"0\" max=\"100\" step=\"0.1\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // Toggle tax rate container visibility\n tc.querySelector('#tax-enabled')?.addEventListener('change', (e) => {\n const container = tc.querySelector('#tax-rate-container');\n if (container) {\n container.style.display = e.target.checked ? 'flex' : 'none';\n }\n });\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#save-tax-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const taxEnabled = tc.querySelector('#tax-enabled').checked;\n const taxRate = parseFloat(tc.querySelector('#tax-rate').value) || 0;\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.taxEnabled = taxEnabled;\n settings.taxRate = taxRate;\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n await store.saveSettings(settings);\n showToast('Financial and Rate settings saved successfully', 'success');\n renderContent();\n } catch (err) {\n console.error('Error saving tax and rate settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-portal-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n await store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving customer portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'system') {\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n const currentTheme = localStorage.getItem('simpro_theme') || 'light';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\" data-tooltip=\"Restore default sandbox demo database\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\" data-tooltip=\"Permanently delete all local database records\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n ${currentUser.role === 'admin' ? `\n <hr style=\"margin:var(--space-md) 0; border:none; border-top:1px dashed var(--border-color);\" />\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups or seed a single test sample to explore the blank app layout.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed exactly one sample record of each entity\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed One Example Version\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n </div>\n ` : ''}\n </div>\n </div>\n </div>\n\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">System Color Theme</label>\n <select class=\"form-select\" id=\"system-theme-select\" style=\"width:100%\">\n ${Object.entries(THEMES).map(([key, val]) => `\n <option value=\"${key}\" ${currentTheme === key ? 'selected' : ''}>${val.name}</option>\n `).join('')}\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Select a color profile and visual background effect for your user interface.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', async (e) => {\n const select = e.target;\n select.disabled = true;\n try {\n const settings = store.getSettings();\n settings.tooltipPreference = select.value;\n await store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved successfully', 'success');\n } catch (err) {\n console.error('Error saving tooltip preference:', err);\n showToast('Failed to save preferences: ' + (err.message || err), 'error');\n } finally {\n select.disabled = false;\n }\n });\n\n tc.querySelector('#system-theme-select')?.addEventListener('change', (e) => {\n applyTheme(e.target.value);\n showToast('System theme updated successfully', 'success');\n });\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a minimal example dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load <strong>exactly one example</strong> of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for quick feature walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed One Example Version\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Example\",\n className: \"btn-primary\",\n onClick: (close) => {\n close();\n seedMinimalData();\n showToast('Single-item example seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', username: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Username</label>\n <input class=\"form-input\" id=\"u-username\" value=\"${t.username || (t.email ? t.email.split('@')[0] : '')}\" ${editId ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''} placeholder=\"e.g. joshua\" />\n ${!editId ? `\n <div style=\"font-size: 11px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500;\">\n Company login code is: <strong style=\"color: var(--color-primary)\">${companySlug}</strong>. User will log in with <strong style=\"color: var(--color-primary)\">username@${companySlug}</strong>\n </div>\n ` : ''}\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">${editId ? 'Reset Password (leave blank to keep current)' : 'Temporary Password (assigned for worker login)'}</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"${editId ? '••••••••' : 'Min. 6 characters'}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\" ${t.userTypeId === 'ut_admin' ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''}>\n <option value=\"\">-- Select --</option>\n ${userTypes\n .filter(ut => ut.id !== 'ut_admin' || t.userTypeId === 'ut_admin')\n .map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value.trim();\n const username = document.getElementById('u-username').value.trim();\n const role = document.getElementById('u-role').value.trim();\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = document.getElementById('u-password')?.value || '';\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!username) { showToast('Username required', 'error'); return; }\n if (username.includes('@')) { showToast('Username cannot contain @ symbol', 'error'); return; }\n \n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (password && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n const updates = { name, username, role, userTypeId, color, payRate };\n if (password) {\n updates.password = password;\n }\n\n if (editId) {\n await store.update('technicians', editId, updates);\n } else {\n await store.create('technicians', updates);\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = async () => {\n const btn = tc.querySelector('#btn-save-materials');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n await store.saveSettings(updatedSettings);\n showToast('Material settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving material settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-10023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-88129',\n originalQuoteNumber: 'Q-54412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' },\n ballet: { name: 'Ballet Pointe', preset: 'ballet', accentColor: '#e58799', headerBg: '#5c2c36', accentTint: '#fff5f6', fontFamily: 'serif' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-company-name\" style=\"width:16px; height:16px\" ${dt.hideCompanyName ? 'checked' : ''} />\n <label for=\"theme-hide-company-name\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide company name text (use logo only)</label>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Variation</label>\n <select class=\"form-select\" id=\"theme-logo-source\" style=\"width:100%\">\n <option value=\"large\" ${dt.logoSource === 'large' ? 'selected' : ''}>Standard Logo (Large)</option>\n <option value=\"small\" ${dt.logoSource === 'small' ? 'selected' : ''}>Small / Shrunk Logo (Small)</option>\n </select>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Hide Company Name toggle\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) {\n hideCompanyNameChk.addEventListener('change', () => {\n dt.hideCompanyName = hideCompanyNameChk.checked;\n updatePreview();\n });\n }\n\n // Logo Variation toggle\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) {\n logoSourceSelect.addEventListener('change', (e) => {\n dt.logoSource = e.target.value;\n updatePreview();\n });\n }\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-theme-save');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n await store.saveSettings(freshSettings);\n showToast('Document customization settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving theme settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n hideCompanyName: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) hideCompanyNameChk.checked = !!dt.hideCompanyName;\n\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) logoSourceSelect.value = dt.logoSource || 'large';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n const logoUrl = dt.logoSource === 'small' ? (settings.logoSmall || settings.logo) : (settings.logo || settings.logoSmall);\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.src = logoUrl || '';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // Company Name visibility\n const companyNameEl = frameDoc.querySelector('.pdf-company-name');\n if (companyNameEl) {\n companyNameEl.style.display = dt.hideCompanyName ? 'none' : 'block';\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n }\n\n renderMarkup();\n }\n\n\n"}, "66e6f1493939dfef7293b734d066895b286c3f48": {"t": 1785754195.0442429, "n": 176991, "h": "95be92a944243753", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\nimport { applyTheme, THEMES } from '../utils/theme.js';\n\n// Compress uploaded images using Canvas to avoid huge Base64 data payloads\nfunction compressImage(dataUrl, maxWidth, maxHeight) {\n return new Promise((resolve) => {\n if (!dataUrl) {\n resolve(dataUrl);\n return;\n }\n // SVG vectors are already lightweight; do not compress them\n if (dataUrl.startsWith('data:image/svg+xml')) {\n resolve(dataUrl);\n return;\n }\n const img = new Image();\n img.onload = () => {\n let width = img.width;\n let height = img.height;\n\n if (width > height) {\n if (width > maxWidth) {\n height = Math.round((height * maxWidth) / width);\n width = maxWidth;\n }\n } else {\n if (height > maxHeight) {\n width = Math.round((width * maxHeight) / height);\n height = maxHeight;\n }\n }\n\n const canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n\n const ctx = canvas.getContext('2d');\n ctx.drawImage(img, 0, 0, width, height);\n\n const isPng = dataUrl.includes('image/png');\n const format = isPng ? 'image/png' : 'image/jpeg';\n const quality = isPng ? undefined : 0.85;\n\n resolve(canvas.toDataURL(format, quality));\n };\n img.onerror = () => {\n resolve(dataUrl);\n };\n img.src = dataUrl;\n });\n}\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''}\" data-tab=\"portal\">Customer Portal</button>\n <button class=\"tab ${activeTab === 'invoices_quotes' ? 'active' : ''}\" data-tab=\"invoices_quotes\">Quotes &amp; Invoices</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Standard Logo (Large) using Canvas to ~600x300 max bounding box\n compressImage(re.target.result, 600, 300).then((compressed) => {\n pendingLogo = compressed;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n // Compress the Small/Shrunk Logo using Canvas to ~200x100 max bounding box\n compressImage(re.target.result, 200, 100).then((compressed) => {\n pendingLogoSmall = compressed;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n });\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n tc.querySelector('#btn-save-company').addEventListener('click', async () => {\n const saveBtn = tc.querySelector('#btn-save-company');\n const originalHtml = saveBtn.innerHTML;\n saveBtn.disabled = true;\n saveBtn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving Changes...';\n\n try {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n await store.saveSettings(settings);\n showToast('Company information saved successfully to cloud database', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n } catch (err) {\n console.error('Error saving company profile settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n saveBtn.disabled = false;\n saveBtn.innerHTML = originalHtml;\n }\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div style=\"background:rgba(59, 130, 246, 0.1); border-left:4px solid #3b82f6; padding:12px 16px; margin-bottom:var(--space-md); border-radius:4px; font-size:13px; color:#f8fafc; display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:#3b82f6; font-size:18px;\">info</span>\n <span>Your company login code is <strong style=\"color:#60a5fa\">${companySlug}</strong>. Technicians log in using <strong style=\"color:#60a5fa\">username@${companySlug}</strong>.</span>\n </span>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Username</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n ${ut?.id !== 'ut_admin' ? `\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n ` : ''}\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Default Tax Rate (GST)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" type=\"number\" value=\"10\" style=\"width:100px\" disabled /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#save-tax-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n await store.saveSettings(settings);\n showToast('Financial and Rate settings saved successfully', 'success');\n renderContent();\n } catch (err) {\n console.error('Error saving tax and rate settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-save-portal-settings');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n await store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving customer portal settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n } else if (activeTab === 'system') {\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n const currentTheme = localStorage.getItem('simpro_theme') || 'light';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\" data-tooltip=\"Restore default sandbox demo database\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\" data-tooltip=\"Permanently delete all local database records\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n ${currentUser.role === 'admin' ? `\n <hr style=\"margin:var(--space-md) 0; border:none; border-top:1px dashed var(--border-color);\" />\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups or seed a single test sample to explore the blank app layout.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed exactly one sample record of each entity\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed One Example Version\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n </div>\n ` : ''}\n </div>\n </div>\n </div>\n\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">System Color Theme</label>\n <select class=\"form-select\" id=\"system-theme-select\" style=\"width:100%\">\n ${Object.entries(THEMES).map(([key, val]) => `\n <option value=\"${key}\" ${currentTheme === key ? 'selected' : ''}>${val.name}</option>\n `).join('')}\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Select a color profile and visual background effect for your user interface.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', async (e) => {\n const select = e.target;\n select.disabled = true;\n try {\n const settings = store.getSettings();\n settings.tooltipPreference = select.value;\n await store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved successfully', 'success');\n } catch (err) {\n console.error('Error saving tooltip preference:', err);\n showToast('Failed to save preferences: ' + (err.message || err), 'error');\n } finally {\n select.disabled = false;\n }\n });\n\n tc.querySelector('#system-theme-select')?.addEventListener('change', (e) => {\n applyTheme(e.target.value);\n showToast('System theme updated successfully', 'success');\n });\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a minimal example dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load <strong>exactly one example</strong> of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for quick feature walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed One Example Version\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Example\",\n className: \"btn-primary\",\n onClick: (close) => {\n close();\n seedMinimalData();\n showToast('Single-item example seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', username: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Username</label>\n <input class=\"form-input\" id=\"u-username\" value=\"${t.username || (t.email ? t.email.split('@')[0] : '')}\" ${editId ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''} placeholder=\"e.g. joshua\" />\n ${!editId ? `\n <div style=\"font-size: 11px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500;\">\n Company login code is: <strong style=\"color: var(--color-primary)\">${companySlug}</strong>. User will log in with <strong style=\"color: var(--color-primary)\">username@${companySlug}</strong>\n </div>\n ` : ''}\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">${editId ? 'Reset Password (leave blank to keep current)' : 'Temporary Password (assigned for worker login)'}</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"${editId ? '••••••••' : 'Min. 6 characters'}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\" ${t.userTypeId === 'ut_admin' ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''}>\n <option value=\"\">-- Select --</option>\n ${userTypes\n .filter(ut => ut.id !== 'ut_admin' || t.userTypeId === 'ut_admin')\n .map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value.trim();\n const username = document.getElementById('u-username').value.trim();\n const role = document.getElementById('u-role').value.trim();\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = document.getElementById('u-password')?.value || '';\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!username) { showToast('Username required', 'error'); return; }\n if (username.includes('@')) { showToast('Username cannot contain @ symbol', 'error'); return; }\n \n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (password && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n const updates = { name, username, role, userTypeId, color, payRate };\n if (password) {\n updates.password = password;\n }\n\n if (editId) {\n await store.update('technicians', editId, updates);\n } else {\n await store.create('technicians', updates);\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = async () => {\n const btn = tc.querySelector('#btn-save-materials');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n await store.saveSettings(updatedSettings);\n showToast('Material settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving material settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-10023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-88129',\n originalQuoteNumber: 'Q-54412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-company-name\" style=\"width:16px; height:16px\" ${dt.hideCompanyName ? 'checked' : ''} />\n <label for=\"theme-hide-company-name\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide company name text (use logo only)</label>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Variation</label>\n <select class=\"form-select\" id=\"theme-logo-source\" style=\"width:100%\">\n <option value=\"large\" ${dt.logoSource === 'large' ? 'selected' : ''}>Standard Logo (Large)</option>\n <option value=\"small\" ${dt.logoSource === 'small' ? 'selected' : ''}>Small / Shrunk Logo (Small)</option>\n </select>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Hide Company Name toggle\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) {\n hideCompanyNameChk.addEventListener('change', () => {\n dt.hideCompanyName = hideCompanyNameChk.checked;\n updatePreview();\n });\n }\n\n // Logo Variation toggle\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) {\n logoSourceSelect.addEventListener('change', (e) => {\n dt.logoSource = e.target.value;\n updatePreview();\n });\n }\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', async () => {\n const btn = tc.querySelector('#btn-theme-save');\n const origHtml = btn.innerHTML;\n btn.disabled = true;\n btn.innerHTML = '<span class=\"material-icons-outlined spinner\" style=\"font-size:16px; margin-right:4px; animation: spin 1s linear infinite\">sync</span> Saving...';\n\n try {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n await store.saveSettings(freshSettings);\n showToast('Document customization settings saved successfully', 'success');\n } catch (err) {\n console.error('Error saving theme settings:', err);\n showToast('Failed to save settings: ' + (err.message || err), 'error');\n } finally {\n btn.disabled = false;\n btn.innerHTML = origHtml;\n }\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n hideCompanyName: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) hideCompanyNameChk.checked = !!dt.hideCompanyName;\n\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) logoSourceSelect.value = dt.logoSource || 'large';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n const logoUrl = dt.logoSource === 'small' ? (settings.logoSmall || settings.logo) : (settings.logo || settings.logoSmall);\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.src = logoUrl || '';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // Company Name visibility\n const companyNameEl = frameDoc.querySelector('.pdf-company-name');\n if (companyNameEl) {\n companyNameEl.style.display = dt.hideCompanyName ? 'none' : 'block';\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n }\n\n renderMarkup();\n }\n\n\n"}, "0294d368bbea6f2edf50f8563679310122062d6a": {"t": 1785754196.693113, "n": 171670, "h": "d762164ab9249d17", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\nimport { applyTheme, THEMES } from '../utils/theme.js';\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''}\" data-tab=\"portal\">Customer Portal</button>\n <button class=\"tab ${activeTab === 'invoices_quotes' ? 'active' : ''}\" data-tab=\"invoices_quotes\">Quotes &amp; Invoices</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogo = re.target.result;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogoSmall = re.target.result;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n tc.querySelector('#btn-save-company').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n store.saveSettings(settings);\n showToast('Company information saved permanently', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div style=\"background:rgba(59, 130, 246, 0.1); border-left:4px solid #3b82f6; padding:12px 16px; margin-bottom:var(--space-md); border-radius:4px; font-size:13px; color:#f8fafc; display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:#3b82f6; font-size:18px;\">info</span>\n <span>Your company login code is <strong style=\"color:#60a5fa\">${companySlug}</strong>. Technicians log in using <strong style=\"color:#60a5fa\">username@${companySlug}</strong>.</span>\n </span>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Username</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n ${ut?.id !== 'ut_admin' ? `\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n ` : ''}\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Default Tax Rate (GST)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" type=\"number\" value=\"10\" style=\"width:100px\" disabled /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', () => {\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n store.saveSettings(settings);\n showToast('Financial and Rate settings saved', 'success');\n renderContent();\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n });\n\n } else if (activeTab === 'system') {\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n const currentTheme = localStorage.getItem('simpro_theme') || 'light';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\" data-tooltip=\"Restore default sandbox demo database\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\" data-tooltip=\"Permanently delete all local database records\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n ${currentUser.role === 'admin' ? `\n <hr style=\"margin:var(--space-md) 0; border:none; border-top:1px dashed var(--border-color);\" />\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups or seed a single test sample to explore the blank app layout.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed exactly one sample record of each entity\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed One Example Version\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n </div>\n ` : ''}\n </div>\n </div>\n </div>\n\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">System Color Theme</label>\n <select class=\"form-select\" id=\"system-theme-select\" style=\"width:100%\">\n ${Object.entries(THEMES).map(([key, val]) => `\n <option value=\"${key}\" ${currentTheme === key ? 'selected' : ''}>${val.name}</option>\n `).join('')}\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Select a color profile and visual background effect for your user interface.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', (e) => {\n const settings = store.getSettings();\n settings.tooltipPreference = e.target.value;\n store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved', 'success');\n });\n\n tc.querySelector('#system-theme-select')?.addEventListener('change', (e) => {\n applyTheme(e.target.value);\n showToast('System theme updated successfully', 'success');\n });\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a minimal example dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load <strong>exactly one example</strong> of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for quick feature walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed One Example Version\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Example\",\n className: \"btn-primary\",\n onClick: (close) => {\n close();\n seedMinimalData();\n showToast('Single-item example seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', username: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Username</label>\n <input class=\"form-input\" id=\"u-username\" value=\"${t.username || (t.email ? t.email.split('@')[0] : '')}\" ${editId ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''} placeholder=\"e.g. joshua\" />\n ${!editId ? `\n <div style=\"font-size: 11px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500;\">\n Company login code is: <strong style=\"color: var(--color-primary)\">${companySlug}</strong>. User will log in with <strong style=\"color: var(--color-primary)\">username@${companySlug}</strong>\n </div>\n ` : ''}\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">${editId ? 'Reset Password (leave blank to keep current)' : 'Temporary Password (assigned for worker login)'}</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"${editId ? '••••••••' : 'Min. 6 characters'}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\" ${t.userTypeId === 'ut_admin' ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''}>\n <option value=\"\">-- Select --</option>\n ${userTypes\n .filter(ut => ut.id !== 'ut_admin' || t.userTypeId === 'ut_admin')\n .map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value.trim();\n const username = document.getElementById('u-username').value.trim();\n const role = document.getElementById('u-role').value.trim();\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = document.getElementById('u-password')?.value || '';\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!username) { showToast('Username required', 'error'); return; }\n if (username.includes('@')) { showToast('Username cannot contain @ symbol', 'error'); return; }\n \n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (password && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n const updates = { name, username, role, userTypeId, color, payRate };\n if (password) {\n updates.password = password;\n }\n\n if (editId) {\n await store.update('technicians', editId, updates);\n } else {\n await store.create('technicians', updates);\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = () => {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n store.saveSettings(updatedSettings);\n showToast('Material settings saved', 'success');\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-10023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-88129',\n originalQuoteNumber: 'Q-54412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-company-name\" style=\"width:16px; height:16px\" ${dt.hideCompanyName ? 'checked' : ''} />\n <label for=\"theme-hide-company-name\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide company name text (use logo only)</label>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Variation</label>\n <select class=\"form-select\" id=\"theme-logo-source\" style=\"width:100%\">\n <option value=\"large\" ${dt.logoSource === 'large' ? 'selected' : ''}>Standard Logo (Large)</option>\n <option value=\"small\" ${dt.logoSource === 'small' ? 'selected' : ''}>Small / Shrunk Logo (Small)</option>\n </select>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Hide Company Name toggle\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) {\n hideCompanyNameChk.addEventListener('change', () => {\n dt.hideCompanyName = hideCompanyNameChk.checked;\n updatePreview();\n });\n }\n\n // Logo Variation toggle\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) {\n logoSourceSelect.addEventListener('change', (e) => {\n dt.logoSource = e.target.value;\n updatePreview();\n });\n }\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', () => {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n store.saveSettings(freshSettings);\n showToast('Document customization settings saved permanently', 'success');\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n logoSource: 'large',\n hideCompanyName: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n const hideCompanyNameChk = tc.querySelector('#theme-hide-company-name');\n if (hideCompanyNameChk) hideCompanyNameChk.checked = !!dt.hideCompanyName;\n\n const logoSourceSelect = tc.querySelector('#theme-logo-source');\n if (logoSourceSelect) logoSourceSelect.value = dt.logoSource || 'large';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n const logoUrl = dt.logoSource === 'small' ? (settings.logoSmall || settings.logo) : (settings.logo || settings.logoSmall);\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.src = logoUrl || '';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // Company Name visibility\n const companyNameEl = frameDoc.querySelector('.pdf-company-name');\n if (companyNameEl) {\n companyNameEl.style.display = dt.hideCompanyName ? 'none' : 'block';\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n }\n\n renderMarkup();\n }\n\n\n"}, "70c10c98ffeb3b723c12476ff5377fa276d4b3fe": {"t": 1785754198.3802316, "n": 169375, "h": "d5df4840687982ea", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\nimport { applyTheme, THEMES } from '../utils/theme.js';\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''}\" data-tab=\"portal\">Customer Portal</button>\n <button class=\"tab ${activeTab === 'invoices_quotes' ? 'active' : ''}\" data-tab=\"invoices_quotes\">Quotes &amp; Invoices</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogo = re.target.result;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogoSmall = re.target.result;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n tc.querySelector('#btn-save-company').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n store.saveSettings(settings);\n showToast('Company information saved permanently', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div style=\"background:rgba(59, 130, 246, 0.1); border-left:4px solid #3b82f6; padding:12px 16px; margin-bottom:var(--space-md); border-radius:4px; font-size:13px; color:#f8fafc; display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:#3b82f6; font-size:18px;\">info</span>\n <span>Your company login code is <strong style=\"color:#60a5fa\">${companySlug}</strong>. Technicians log in using <strong style=\"color:#60a5fa\">username@${companySlug}</strong>.</span>\n </span>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Username</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n ${ut?.id !== 'ut_admin' ? `\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n ` : ''}\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Default Tax Rate (GST)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" type=\"number\" value=\"10\" style=\"width:100px\" disabled /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', () => {\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n store.saveSettings(settings);\n showToast('Financial and Rate settings saved', 'success');\n renderContent();\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n });\n\n } else if (activeTab === 'system') {\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n const currentTheme = localStorage.getItem('simpro_theme') || 'light';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\" data-tooltip=\"Restore default sandbox demo database\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\" data-tooltip=\"Permanently delete all local database records\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n ${currentUser.role === 'admin' ? `\n <hr style=\"margin:var(--space-md) 0; border:none; border-top:1px dashed var(--border-color);\" />\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups or seed a single test sample to explore the blank app layout.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed exactly one sample record of each entity\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed One Example Version\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n </div>\n ` : ''}\n </div>\n </div>\n </div>\n\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:16px;\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">System Color Theme</label>\n <select class=\"form-select\" id=\"system-theme-select\" style=\"width:100%\">\n ${Object.entries(THEMES).map(([key, val]) => `\n <option value=\"${key}\" ${currentTheme === key ? 'selected' : ''}>${val.name}</option>\n `).join('')}\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Select a color profile and visual background effect for your user interface.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', (e) => {\n const settings = store.getSettings();\n settings.tooltipPreference = e.target.value;\n store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved', 'success');\n });\n\n tc.querySelector('#system-theme-select')?.addEventListener('change', (e) => {\n applyTheme(e.target.value);\n showToast('System theme updated successfully', 'success');\n });\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a minimal example dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load <strong>exactly one example</strong> of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for quick feature walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed One Example Version\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Example\",\n className: \"btn-primary\",\n onClick: (close) => {\n close();\n seedMinimalData();\n showToast('Single-item example seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', username: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Username</label>\n <input class=\"form-input\" id=\"u-username\" value=\"${t.username || (t.email ? t.email.split('@')[0] : '')}\" ${editId ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''} placeholder=\"e.g. joshua\" />\n ${!editId ? `\n <div style=\"font-size: 11px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500;\">\n Company login code is: <strong style=\"color: var(--color-primary)\">${companySlug}</strong>. User will log in with <strong style=\"color: var(--color-primary)\">username@${companySlug}</strong>\n </div>\n ` : ''}\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">${editId ? 'Reset Password (leave blank to keep current)' : 'Temporary Password (assigned for worker login)'}</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"${editId ? '••••••••' : 'Min. 6 characters'}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\" ${t.userTypeId === 'ut_admin' ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''}>\n <option value=\"\">-- Select --</option>\n ${userTypes\n .filter(ut => ut.id !== 'ut_admin' || t.userTypeId === 'ut_admin')\n .map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value.trim();\n const username = document.getElementById('u-username').value.trim();\n const role = document.getElementById('u-role').value.trim();\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = document.getElementById('u-password')?.value || '';\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!username) { showToast('Username required', 'error'); return; }\n if (username.includes('@')) { showToast('Username cannot contain @ symbol', 'error'); return; }\n \n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (password && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n const updates = { name, username, role, userTypeId, color, payRate };\n if (password) {\n updates.password = password;\n }\n\n if (editId) {\n await store.update('technicians', editId, updates);\n } else {\n await store.create('technicians', updates);\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = () => {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n store.saveSettings(updatedSettings);\n showToast('Material settings saved', 'success');\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-10023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-88129',\n originalQuoteNumber: 'Q-54412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', () => {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n store.saveSettings(freshSettings);\n showToast('Document customization settings saved permanently', 'success');\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n }\n\n renderMarkup();\n }\n\n\n"}, "4fe91cba1b757f76dba24c08464afcb9582e1c5e": {"t": 1785754200.3402407, "n": 168304, "h": "5528d6bd8a1ebe69", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''}\" data-tab=\"portal\">Customer Portal</button>\n <button class=\"tab ${activeTab === 'invoices_quotes' ? 'active' : ''}\" data-tab=\"invoices_quotes\">Quotes &amp; Invoices</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogo = re.target.result;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogoSmall = re.target.result;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n tc.querySelector('#btn-save-company').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n store.saveSettings(settings);\n showToast('Company information saved permanently', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div style=\"background:rgba(59, 130, 246, 0.1); border-left:4px solid #3b82f6; padding:12px 16px; margin-bottom:var(--space-md); border-radius:4px; font-size:13px; color:#f8fafc; display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:#3b82f6; font-size:18px;\">info</span>\n <span>Your company login code is <strong style=\"color:#60a5fa\">${companySlug}</strong>. Technicians log in using <strong style=\"color:#60a5fa\">username@${companySlug}</strong>.</span>\n </span>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Username</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n ${ut?.id !== 'ut_admin' ? `\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n ` : ''}\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Default Tax Rate (GST)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" type=\"number\" value=\"10\" style=\"width:100px\" disabled /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', () => {\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n store.saveSettings(settings);\n showToast('Financial and Rate settings saved', 'success');\n renderContent();\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n });\n\n } else if (activeTab === 'system') {\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\" data-tooltip=\"Restore default sandbox demo database\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\" data-tooltip=\"Permanently delete all local database records\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n ${currentUser.role === 'admin' ? `\n <hr style=\"margin:var(--space-md) 0; border:none; border-top:1px dashed var(--border-color);\" />\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups or seed a single test sample to explore the blank app layout.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed exactly one sample record of each entity\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed One Example Version\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n </div>\n ` : ''}\n </div>\n </div>\n </div>\n\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', (e) => {\n const settings = store.getSettings();\n settings.tooltipPreference = e.target.value;\n store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved', 'success');\n });\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a minimal example dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load <strong>exactly one example</strong> of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for quick feature walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed One Example Version\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Example\",\n className: \"btn-primary\",\n onClick: (close) => {\n close();\n seedMinimalData();\n showToast('Single-item example seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', username: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Username</label>\n <input class=\"form-input\" id=\"u-username\" value=\"${t.username || (t.email ? t.email.split('@')[0] : '')}\" ${editId ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''} placeholder=\"e.g. joshua\" />\n ${!editId ? `\n <div style=\"font-size: 11px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500;\">\n Company login code is: <strong style=\"color: var(--color-primary)\">${companySlug}</strong>. User will log in with <strong style=\"color: var(--color-primary)\">username@${companySlug}</strong>\n </div>\n ` : ''}\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">${editId ? 'Reset Password (leave blank to keep current)' : 'Temporary Password (assigned for worker login)'}</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"${editId ? '••••••••' : 'Min. 6 characters'}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\" ${t.userTypeId === 'ut_admin' ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''}>\n <option value=\"\">-- Select --</option>\n ${userTypes\n .filter(ut => ut.id !== 'ut_admin' || t.userTypeId === 'ut_admin')\n .map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value.trim();\n const username = document.getElementById('u-username').value.trim();\n const role = document.getElementById('u-role').value.trim();\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = document.getElementById('u-password')?.value || '';\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!username) { showToast('Username required', 'error'); return; }\n if (username.includes('@')) { showToast('Username cannot contain @ symbol', 'error'); return; }\n \n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (password && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n const updates = { name, username, role, userTypeId, color, payRate };\n if (password) {\n updates.password = password;\n }\n\n if (editId) {\n await store.update('technicians', editId, updates);\n } else {\n await store.create('technicians', updates);\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = () => {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n store.saveSettings(updatedSettings);\n showToast('Material settings saved', 'success');\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-10023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-88129',\n originalQuoteNumber: 'Q-54412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', () => {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n store.saveSettings(freshSettings);\n showToast('Document customization settings saved permanently', 'success');\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n }\n\n renderMarkup();\n }\n\n\n"}, "d4be27b025f8f1f01aeea09106b5c48bd1c08ecb": {"t": 1785754202.2528124, "n": 168024, "h": "71f7bb1cd353e7ff", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''}\" data-tab=\"portal\">Customer Portal</button>\n <button class=\"tab ${activeTab === 'invoices_quotes' ? 'active' : ''}\" data-tab=\"invoices_quotes\">Quotes &amp; Invoices</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogo = re.target.result;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogoSmall = re.target.result;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n tc.querySelector('#btn-save-company').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n store.saveSettings(settings);\n showToast('Company information saved permanently', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div style=\"background:rgba(59, 130, 246, 0.1); border-left:4px solid #3b82f6; padding:12px 16px; margin-bottom:var(--space-md); border-radius:4px; font-size:13px; color:#f8fafc; display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"color:#3b82f6; font-size:18px;\">info</span>\n <span>Your company login code is <strong style=\"color:#60a5fa\">${companySlug}</strong>. Technicians log in using <strong style=\"color:#60a5fa\">username@${companySlug}</strong>.</span>\n </span>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Username</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Default Tax Rate (GST)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" type=\"number\" value=\"10\" style=\"width:100px\" disabled /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', () => {\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n store.saveSettings(settings);\n showToast('Financial and Rate settings saved', 'success');\n renderContent();\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n });\n\n } else if (activeTab === 'system') {\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\" data-tooltip=\"Restore default sandbox demo database\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\" data-tooltip=\"Permanently delete all local database records\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n ${currentUser.role === 'admin' ? `\n <hr style=\"margin:var(--space-md) 0; border:none; border-top:1px dashed var(--border-color);\" />\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups or seed a single test sample to explore the blank app layout.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed exactly one sample record of each entity\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed One Example Version\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n </div>\n ` : ''}\n </div>\n </div>\n </div>\n\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', (e) => {\n const settings = store.getSettings();\n settings.tooltipPreference = e.target.value;\n store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved', 'success');\n });\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a minimal example dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load <strong>exactly one example</strong> of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for quick feature walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed One Example Version\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Example\",\n className: \"btn-primary\",\n onClick: (close) => {\n close();\n seedMinimalData();\n showToast('Single-item example seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', username: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n const companySlug = store.getSettings().name.toLowerCase().replace(/[^a-z0-9]/g, '');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Username</label>\n <input class=\"form-input\" id=\"u-username\" value=\"${t.username || (t.email ? t.email.split('@')[0] : '')}\" ${editId ? 'disabled style=\"opacity:0.6; cursor:not-allowed;\"' : ''} placeholder=\"e.g. joshua\" />\n ${!editId ? `\n <div style=\"font-size: 11px; color: var(--text-tertiary); margin-top: 4px; font-weight: 500;\">\n Company login code is: <strong style=\"color: var(--color-primary)\">${companySlug}</strong>. User will log in with <strong style=\"color: var(--color-primary)\">username@${companySlug}</strong>\n </div>\n ` : ''}\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">${editId ? 'Reset Password (leave blank to keep current)' : 'Temporary Password (assigned for worker login)'}</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"${editId ? '••••••••' : 'Min. 6 characters'}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\">\n <option value=\"\">-- Select --</option>\n ${userTypes.map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value.trim();\n const username = document.getElementById('u-username').value.trim();\n const role = document.getElementById('u-role').value.trim();\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = document.getElementById('u-password')?.value || '';\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!username) { showToast('Username required', 'error'); return; }\n if (username.includes('@')) { showToast('Username cannot contain @ symbol', 'error'); return; }\n \n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (password && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n const updates = { name, username, role, userTypeId, color, payRate };\n if (password) {\n updates.password = password;\n }\n\n if (editId) {\n await store.update('technicians', editId, updates);\n } else {\n await store.create('technicians', updates);\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = () => {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n store.saveSettings(updatedSettings);\n showToast('Material settings saved', 'success');\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-10023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-88129',\n originalQuoteNumber: 'Q-54412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', () => {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n store.saveSettings(freshSettings);\n showToast('Document customization settings saved permanently', 'success');\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n }\n\n renderMarkup();\n }\n\n\n"}, "4b1cef56e0e36625f3002d0b0fae8e1ae5d74695": {"t": 1785754203.9185505, "n": 166314, "h": "214d3f037ca67986", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''}\" data-tab=\"portal\">Customer Portal</button>\n <button class=\"tab ${activeTab === 'invoices_quotes' ? 'active' : ''}\" data-tab=\"invoices_quotes\">Quotes &amp; Invoices</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Company Name'}\" id=\"company-name\" placeholder=\"Company Name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || ''}\" placeholder=\"e.g. 51 234 567 890\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || ''}\" placeholder=\"e.g. (02) 6882 4400\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || ''}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || ''}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\" placeholder=\"e.g. 14 Yarrandale Rd, Dubbo NSW 2830\">${s.address || ''}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogo = re.target.result;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogoSmall = re.target.result;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n tc.querySelector('#btn-save-company').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n store.saveSettings(settings);\n showToast('Company information saved permanently', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Email</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.email || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Default Tax Rate (GST)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" type=\"number\" value=\"10\" style=\"width:100px\" disabled /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', () => {\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n store.saveSettings(settings);\n showToast('Financial and Rate settings saved', 'success');\n renderContent();\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n });\n\n } else if (activeTab === 'system') {\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\" data-tooltip=\"Restore default sandbox demo database\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\" data-tooltip=\"Permanently delete all local database records\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n ${currentUser.role === 'admin' ? `\n <hr style=\"margin:var(--space-md) 0; border:none; border-top:1px dashed var(--border-color);\" />\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups or seed a single test sample to explore the blank app layout.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed exactly one sample record of each entity\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed One Example Version\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n </div>\n ` : ''}\n </div>\n </div>\n </div>\n\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', (e) => {\n const settings = store.getSettings();\n settings.tooltipPreference = e.target.value;\n store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved', 'success');\n });\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a minimal example dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load <strong>exactly one example</strong> of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for quick feature walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed One Example Version\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Example\",\n className: \"btn-primary\",\n onClick: (close) => {\n close();\n seedMinimalData();\n showToast('Single-item example seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', email: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Email</label>\n <input class=\"form-input\" id=\"u-email\" value=\"${t.email || ''}\" />\n </div>\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Temporary Password (assigned for worker login)</label>\n <input class=\"form-input\" id=\"u-password\" type=\"password\" placeholder=\"Min. 6 characters\" />\n </div>\n ` : ''}\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\">\n <option value=\"\">-- Select --</option>\n ${userTypes.map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value;\n const email = document.getElementById('u-email').value;\n const role = document.getElementById('u-role').value;\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n const password = editId ? null : (document.getElementById('u-password')?.value || '');\n \n if (!name) { showToast('Name required', 'error'); return; }\n if (!editId && !password) { showToast('Password required', 'error'); return; }\n if (!editId && password.length < 6) { showToast('Password must be at least 6 characters', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n if (editId) {\n await store.update('technicians', editId, { name, email, role, userTypeId, color, payRate });\n } else {\n await store.create('technicians', { name, email, password, role, userTypeId, color, payRate });\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = () => {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n store.saveSettings(updatedSettings);\n showToast('Material settings saved', 'success');\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-10023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-88129',\n originalQuoteNumber: 'Q-54412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', () => {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n store.saveSettings(freshSettings);\n showToast('Document customization settings saved permanently', 'success');\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n }\n\n renderMarkup();\n }\n\n\n"}, "4bdc1be164b606b58a7504b61ec71351639b9eee": {"t": 1785754206.0474064, "n": 165704, "h": "a2920ae1e570d399", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''}\" data-tab=\"portal\">Customer Portal</button>\n <button class=\"tab ${activeTab === 'invoices_quotes' ? 'active' : ''}\" data-tab=\"invoices_quotes\">Quotes &amp; Invoices</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Apex Power Services'}\" id=\"company-name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || '51 234 567 890'}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || '(02) 6882 4400'}\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || 'apexpowerservices.com.au'}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || 'admin@apexpowerservices.com.au'}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\">${s.address || '14 Yarrandale Rd, Dubbo NSW 2830'}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogo = re.target.result;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogoSmall = re.target.result;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n tc.querySelector('#btn-save-company').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n store.saveSettings(settings);\n showToast('Company information saved permanently', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Email</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.email || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Default Tax Rate (GST)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" type=\"number\" value=\"10\" style=\"width:100px\" disabled /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', () => {\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n store.saveSettings(settings);\n showToast('Financial and Rate settings saved', 'success');\n renderContent();\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n });\n\n } else if (activeTab === 'system') {\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\" data-tooltip=\"Restore default sandbox demo database\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\" data-tooltip=\"Permanently delete all local database records\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n ${currentUser.role === 'admin' ? `\n <hr style=\"margin:var(--space-md) 0; border:none; border-top:1px dashed var(--border-color);\" />\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups or seed a single test sample to explore the blank app layout.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed exactly one sample record of each entity\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed One Example Version\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n </div>\n ` : ''}\n </div>\n </div>\n </div>\n\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', (e) => {\n const settings = store.getSettings();\n settings.tooltipPreference = e.target.value;\n store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved', 'success');\n });\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a minimal example dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load <strong>exactly one example</strong> of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for quick feature walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed One Example Version\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Example\",\n className: \"btn-primary\",\n onClick: (close) => {\n close();\n seedMinimalData();\n showToast('Single-item example seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', email: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Email</label>\n <input class=\"form-input\" id=\"u-email\" value=\"${t.email || ''}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\">\n <option value=\"\">-- Select --</option>\n ${userTypes.map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary btn-save-user', onClick: async (c) => {\n const name = document.getElementById('u-name').value;\n const email = document.getElementById('u-email').value;\n const role = document.getElementById('u-role').value;\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n const saveBtn = document.querySelector('.btn-save-user');\n if (saveBtn) {\n saveBtn.disabled = true;\n saveBtn.innerHTML = 'Saving...';\n }\n\n try {\n if (editId) {\n await store.update('technicians', editId, { name, email, role, userTypeId, color, payRate });\n } else {\n await store.create('technicians', { name, email, role, userTypeId, color, payRate });\n }\n showToast('User saved successfully', 'success');\n renderContent();\n c();\n } catch (err) {\n showToast(err.message || 'Failed to save user.', 'error');\n if (saveBtn) {\n saveBtn.disabled = false;\n saveBtn.innerHTML = 'Save';\n }\n }\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = () => {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n store.saveSettings(updatedSettings);\n showToast('Material settings saved', 'success');\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-10023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-88129',\n originalQuoteNumber: 'Q-54412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', () => {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n store.saveSettings(freshSettings);\n showToast('Document customization settings saved permanently', 'success');\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n }\n\n renderMarkup();\n }\n\n\n"}, "54aa50b4c56247530ac58eb2e707b4301520f812": {"t": 1785754207.9574668, "n": 165205, "h": "d0d073e4e8c896e9", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''}\" data-tab=\"portal\">Customer Portal</button>\n <button class=\"tab ${activeTab === 'invoices_quotes' ? 'active' : ''}\" data-tab=\"invoices_quotes\">Quotes &amp; Invoices</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Apex Power Services'}\" id=\"company-name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || '51 234 567 890'}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || '(02) 6882 4400'}\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || 'apexpowerservices.com.au'}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || 'admin@apexpowerservices.com.au'}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\">${s.address || '14 Yarrandale Rd, Dubbo NSW 2830'}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogo = re.target.result;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogoSmall = re.target.result;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n tc.querySelector('#btn-save-company').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n store.saveSettings(settings);\n showToast('Company information saved permanently', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Email</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.email || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Default Tax Rate (GST)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" type=\"number\" value=\"10\" style=\"width:100px\" disabled /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', () => {\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n store.saveSettings(settings);\n showToast('Financial and Rate settings saved', 'success');\n renderContent();\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n });\n\n } else if (activeTab === 'system') {\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\" data-tooltip=\"Restore default sandbox demo database\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\" data-tooltip=\"Permanently delete all local database records\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n ${currentUser.role === 'admin' ? `\n <hr style=\"margin:var(--space-md) 0; border:none; border-top:1px dashed var(--border-color);\" />\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups or seed a single test sample to explore the blank app layout.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed exactly one sample record of each entity\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed One Example Version\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n </div>\n ` : ''}\n </div>\n </div>\n </div>\n\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', (e) => {\n const settings = store.getSettings();\n settings.tooltipPreference = e.target.value;\n store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved', 'success');\n });\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a minimal example dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load <strong>exactly one example</strong> of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for quick feature walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed One Example Version\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Example\",\n className: \"btn-primary\",\n onClick: (close) => {\n close();\n seedMinimalData();\n showToast('Single-item example seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', email: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Email</label>\n <input class=\"form-input\" id=\"u-email\" value=\"${t.email || ''}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\">\n <option value=\"\">-- Select --</option>\n ${userTypes.map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('u-name').value;\n const email = document.getElementById('u-email').value;\n const role = document.getElementById('u-role').value;\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('technicians', editId, { name, email, role, userTypeId, color, payRate });\n } else {\n store.create('technicians', { name, email, role, userTypeId, color, payRate });\n }\n showToast('User saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = () => {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n store.saveSettings(updatedSettings);\n showToast('Material settings saved', 'success');\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-10023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-88129',\n originalQuoteNumber: 'Q-54412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n syncControlsFromState();\n updatePreview();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview(true); // Switch tab = full reload to replace layout\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', () => {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n store.saveSettings(freshSettings);\n showToast('Document customization settings saved permanently', 'success');\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n };\n syncControlsFromState();\n updatePreview();\n }\n });\n }\n\n function syncControlsFromState() {\n // 1. Preset cards active states\n tc.querySelectorAll('.preset-card').forEach(card => {\n const key = card.dataset.preset;\n const isActive = dt.preset === key;\n if (isActive) {\n card.classList.add('active');\n card.style.borderColor = 'var(--color-primary)';\n card.style.background = 'var(--color-primary-light)';\n card.style.color = 'var(--color-primary)';\n } else {\n card.classList.remove('active');\n card.style.borderColor = 'var(--border-color)';\n card.style.background = 'var(--card-bg)';\n card.style.color = 'inherit';\n }\n });\n\n // 2. Custom style panel visibility\n const customStylePanel = tc.querySelector('#custom-style-controls');\n if (customStylePanel) {\n customStylePanel.style.display = dt.preset === 'custom' ? 'block' : 'none';\n }\n\n // 3. Custom color / font inputs\n const accentColorVal = tc.querySelector('[data-prop=\"accentColor\"]');\n if (accentColorVal) accentColorVal.value = dt.accentColor;\n const accentColorText = tc.querySelector('#color-accent-text');\n if (accentColorText) accentColorText.value = dt.accentColor;\n\n const headerBgVal = tc.querySelector('[data-prop=\"headerBg\"]');\n if (headerBgVal) headerBgVal.value = dt.headerBg;\n const headerBgText = tc.querySelector('#color-header-text');\n if (headerBgText) headerBgText.value = dt.headerBg;\n\n const accentTintVal = tc.querySelector('[data-prop=\"accentTint\"]');\n if (accentTintVal) accentTintVal.value = dt.accentTint;\n const accentTintText = tc.querySelector('#color-tint-text');\n if (accentTintText) accentTintText.value = dt.accentTint;\n\n const fontFamilySelect = tc.querySelector('[data-prop=\"fontFamily\"]');\n if (fontFamilySelect) fontFamilySelect.value = dt.fontFamily;\n\n // 4. Logo visibility checkbox\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n if (hideLogoChk) hideLogoChk.checked = !!dt.hideLogo;\n\n const logoControls = tc.querySelector('#logo-branding-controls');\n if (logoControls) logoControls.style.display = dt.hideLogo ? 'none' : 'flex';\n\n // 5. Logo Alignment buttons\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n if (btn.dataset.align === dt.logoAlignment) {\n btn.classList.add('btn-primary');\n } else {\n btn.classList.remove('btn-primary');\n }\n });\n\n // 6. Logo Scale slider & label\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n if (scaleSlider) scaleSlider.value = dt.logoScale || 60;\n const scaleLabel = tc.querySelector('#scale-val-label');\n if (scaleLabel) scaleLabel.textContent = (dt.logoScale || 60) + 'px';\n\n // 7. Text Copy inputs\n const invoiceTitleInp = tc.querySelector('[data-prop=\"invoiceTitle\"]');\n if (invoiceTitleInp) invoiceTitleInp.value = dt.invoiceTitle || '';\n\n const invoicePaymentTermsInp = tc.querySelector('[data-prop=\"invoicePaymentTerms\"]');\n if (invoicePaymentTermsInp) invoicePaymentTermsInp.value = dt.invoicePaymentTerms || '';\n\n const invoiceTermsInp = tc.querySelector('[data-prop=\"invoiceTerms\"]');\n if (invoiceTermsInp) invoiceTermsInp.value = dt.invoiceTerms || '';\n\n const quoteTitleInp = tc.querySelector('[data-prop=\"quoteTitle\"]');\n if (quoteTitleInp) quoteTitleInp.value = dt.quoteTitle || '';\n\n const quoteTermsInp = tc.querySelector('[data-prop=\"quoteTerms\"]');\n if (quoteTermsInp) quoteTermsInp.value = dt.quoteTerms || '';\n\n const footerNoteInp = tc.querySelector('[data-prop=\"footerNote\"]');\n if (footerNoteInp) footerNoteInp.value = dt.footerNote || '';\n\n // 8. Toggles / Checkboxes\n const stripeChk = tc.querySelector('#theme-stripe');\n if (stripeChk) stripeChk.checked = !!dt.paymentStripe;\n\n const bankTransferChk = tc.querySelector('#theme-bank-transfer');\n if (bankTransferChk) bankTransferChk.checked = !!dt.paymentDirectTransfer;\n\n const cashChk = tc.querySelector('#theme-cash');\n if (cashChk) cashChk.checked = !!dt.paymentCash;\n\n const quoteSigChk = tc.querySelector('#theme-quote-sig');\n if (quoteSigChk) quoteSigChk.checked = !!dt.quoteSignature;\n }\n\n function updatePreview(fullReload = false) {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const frameDoc = frame.contentDocument || frame.contentWindow.document;\n const hasWrapper = frameDoc && frameDoc.getElementById('document-content-wrapper');\n\n if (fullReload || !hasWrapper) {\n // If we already assigned srcdoc and are not forcing a full reload, wait for it to finish loading\n if (!fullReload && frame.dataset.srcdocAssigned === 'true') {\n return;\n }\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style id=\"theme-preview-styles\">\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n <div id=\"document-content-wrapper\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </div>\n </body>\n </html>\n `;\n \n frame.dataset.srcdocAssigned = 'true';\n frame.onload = () => {\n frame.dataset.srcdocAssigned = 'false';\n frame.onload = null;\n updatePreview();\n };\n frame.srcdoc = html;\n return;\n }\n\n // SELECTIVE DOM PATCHING (No Reload = No Scroll Reset)\n \n // 1. Live update the style tag\n const styleTag = frameDoc.getElementById('theme-preview-styles');\n if (styleTag) {\n styleTag.innerHTML = `\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n `;\n }\n\n // 2. Live update document titles\n const docTypeEl = frameDoc.querySelector('.pdf-doc-type');\n if (docTypeEl) {\n docTypeEl.textContent = activePreviewTab === 'quote' \n ? (dt.quoteTitle || 'PROPOSAL / QUOTE') \n : (dt.invoiceTitle || 'TAX INVOICE');\n }\n\n // 3. Live update logo size and header alignment\n const headerEl = frameDoc.querySelector('.pdf-header');\n const companyEl = frameDoc.querySelector('.pdf-company');\n if (headerEl && companyEl) {\n let headerFlexStyle = 'display:flex; justify-content:space-between; align-items:flex-start;';\n let companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start;';\n let titleBlockStyle = 'text-align: right;';\n \n if (dt.logoAlignment === 'right') {\n companyFlexStyle = 'display:flex; gap:14px; align-items:flex-start; flex-direction: row-reverse; text-align: right;';\n } else if (dt.logoAlignment === 'center') {\n headerFlexStyle = 'display:flex; flex-direction:column; align-items:center; gap:20px; border-bottom: 2px solid #E4E9F0; padding-bottom: 24px; margin-bottom: 32px;';\n companyFlexStyle = 'display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;';\n titleBlockStyle = 'text-align: center; margin-top: 10px;';\n }\n \n headerEl.style.cssText = headerFlexStyle;\n companyEl.style.cssText = companyFlexStyle;\n \n const titleBlockEl = frameDoc.querySelector('.pdf-title-block');\n if (titleBlockEl) titleBlockEl.style.cssText = titleBlockStyle;\n }\n\n // Logo Scale / Visibility\n const logoImg = frameDoc.querySelector('.pdf-logo-img');\n const logoBadge = frameDoc.querySelector('.pdf-logo');\n const logoText = frameDoc.querySelector('.pdf-logo-text');\n\n if (dt.hideLogo) {\n if (logoImg) logoImg.style.display = 'none';\n if (logoBadge) logoBadge.style.display = 'none';\n if (logoText) {\n logoText.style.display = 'block';\n logoText.style.color = dt.accentColor || '#1B6DE0';\n }\n } else {\n if (logoText) logoText.style.display = 'none';\n \n const logoHeight = dt.logoScale !== undefined ? dt.logoScale : 60;\n if (logoImg) {\n logoImg.style.display = 'block';\n logoImg.style.maxHeight = logoHeight + 'px';\n }\n if (logoBadge) {\n logoBadge.style.display = 'flex';\n logoBadge.style.background = `linear-gradient(135deg, ${dt.accentColor || '#1B6DE0'}, ${dt.accentColor || '#1B6DE0'}dd)`;\n }\n }\n\n // 4. Live update Payment Options\n const paymentSection = frameDoc.querySelector('.pdf-payment-methods');\n if (paymentSection) {\n const showPayment = activePreviewTab === 'invoice' && (dt.paymentStripe || dt.paymentDirectTransfer || dt.paymentCash);\n paymentSection.style.display = showPayment ? 'block' : 'none';\n \n const stripeOption = paymentSection.querySelector('.pdf-payment-option-stripe');\n if (stripeOption) {\n stripeOption.style.display = dt.paymentStripe ? 'block' : 'none';\n const stripeBtn = stripeOption.querySelector('a');\n if (stripeBtn) stripeBtn.style.background = dt.accentColor || '#1B6DE0';\n }\n \n const directOption = paymentSection.querySelector('.pdf-payment-option-direct');\n if (directOption) {\n directOption.style.display = dt.paymentDirectTransfer ? 'block' : 'none';\n const bankDetailsEl = directOption.querySelector('.pdf-bank-details');\n if (bankDetailsEl) {\n bankDetailsEl.innerHTML = escapeHTML(dt.invoicePaymentTerms || '').replace(/\\n/g, '<br/>');\n }\n }\n \n const cashOption = paymentSection.querySelector('.pdf-payment-option-cash');\n if (cashOption) {\n cashOption.style.display = dt.paymentCash ? 'block' : 'none';\n }\n }\n\n // 5. Live update Quote Signatures\n const sigSection = frameDoc.querySelector('.pdf-signature-block');\n if (sigSection) {\n const showSig = activePreviewTab === 'quote' && dt.quoteSignature;\n sigSection.style.display = showSig ? 'flex' : 'none';\n }\n\n // 6. Live update Terms in Footer\n const footerTextEl = frameDoc.querySelector('.pdf-footer-text');\n if (footerTextEl) {\n footerTextEl.textContent = activePreviewTab === 'quote'\n ? (dt.quoteTerms || 'This quote is valid for the period shown above. Prices include GST where applicable. Please contact us to accept this quote or if you have any questions.')\n : (dt.invoiceTerms || 'Payment is due by the date shown above. Please reference the invoice number when making payment. Thank you for your business.');\n }\n\n const footerNoteEl = frameDoc.querySelector('.pdf-footer-note');\n if (footerNoteEl) {\n if (dt.footerNote) {\n footerNoteEl.style.display = 'block';\n footerNoteEl.textContent = dt.footerNote;\n } else {\n footerNoteEl.style.display = 'none';\n }\n }\n }\n\n renderMarkup();\n }\n\n\n"}, "8ab93a52a6f4580b0ce43ce3d763cffe95a09257": {"t": 1785754209.5878139, "n": 154535, "h": "e1b7973bb2eec1b3", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''}\" data-tab=\"portal\">Customer Portal</button>\n <button class=\"tab ${activeTab === 'invoices_quotes' ? 'active' : ''}\" data-tab=\"invoices_quotes\">Quotes &amp; Invoices</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Apex Power Services'}\" id=\"company-name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || '51 234 567 890'}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || '(02) 6882 4400'}\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || 'apexpowerservices.com.au'}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || 'admin@apexpowerservices.com.au'}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\">${s.address || '14 Yarrandale Rd, Dubbo NSW 2830'}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogo = re.target.result;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogoSmall = re.target.result;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n tc.querySelector('#btn-save-company').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n store.saveSettings(settings);\n showToast('Company information saved permanently', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Email</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.email || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Default Tax Rate (GST)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" type=\"number\" value=\"10\" style=\"width:100px\" disabled /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', () => {\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n store.saveSettings(settings);\n showToast('Financial and Rate settings saved', 'success');\n renderContent();\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n });\n\n } else if (activeTab === 'system') {\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\" data-tooltip=\"Restore default sandbox demo database\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\" data-tooltip=\"Permanently delete all local database records\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n ${currentUser.role === 'admin' ? `\n <hr style=\"margin:var(--space-md) 0; border:none; border-top:1px dashed var(--border-color);\" />\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups or seed a single test sample to explore the blank app layout.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed exactly one sample record of each entity\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed One Example Version\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n </div>\n ` : ''}\n </div>\n </div>\n </div>\n\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', (e) => {\n const settings = store.getSettings();\n settings.tooltipPreference = e.target.value;\n store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved', 'success');\n });\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a minimal example dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load <strong>exactly one example</strong> of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for quick feature walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed One Example Version\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Example\",\n className: \"btn-primary\",\n onClick: (close) => {\n close();\n seedMinimalData();\n showToast('Single-item example seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', email: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Email</label>\n <input class=\"form-input\" id=\"u-email\" value=\"${t.email || ''}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\">\n <option value=\"\">-- Select --</option>\n ${userTypes.map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('u-name').value;\n const email = document.getElementById('u-email').value;\n const role = document.getElementById('u-role').value;\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('technicians', editId, { name, email, role, userTypeId, color, payRate });\n } else {\n store.create('technicians', { name, email, role, userTypeId, color, payRate });\n }\n showToast('User saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = () => {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n store.saveSettings(updatedSettings);\n showToast('Material settings saved', 'success');\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-10023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-88129',\n originalQuoteNumber: 'Q-54412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n renderMarkup();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('input', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview();\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview();\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', () => {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n store.saveSettings(freshSettings);\n showToast('Document customization settings saved permanently', 'success');\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n };\n renderMarkup();\n }\n });\n }\n\n function updatePreview() {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style>\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </body>\n </html>\n `;\n\n try {\n const doc = frame.contentDocument || frame.contentWindow.document;\n doc.open();\n doc.write(html);\n doc.close();\n } catch (err) {\n console.error('Mock preview document injection error:', err);\n }\n }\n\n renderMarkup();\n }\n\n\n"}, "3e50181e73dd64f844c8068d6053ca361ab15030": {"t": 1785754211.3780062, "n": 154536, "h": "3fcfa01700c6c2c6", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData } from '../data/seed.js';\nimport { getPrintStyles, generateDocument } from '../components/PrintPreview.js';\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''}\" data-tab=\"portal\">Customer Portal</button>\n <button class=\"tab ${activeTab === 'invoices_quotes' ? 'active' : ''}\" data-tab=\"invoices_quotes\">Quotes &amp; Invoices</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'invoices_quotes') {\n renderInvoicesQuotesTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n let pendingLogoSmall = s.logoSmall;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:850px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Apex Power Services'}\" id=\"company-name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || '51 234 567 890'}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || '(02) 6882 4400'}\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || 'apexpowerservices.com.au'}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || 'admin@apexpowerservices.com.au'}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\">${s.address || '14 Yarrandale Rd, Dubbo NSW 2830'}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; gap:20px; align-items:center; text-align:center\">\n \n <!-- Main Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Large / Standard)</label>\n <div id=\"logo-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" data-tooltip=\"Upload new standard company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" data-tooltip=\"Remove custom company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <!-- Small Logo -->\n <div style=\"display:flex; flex-direction:column; align-items:center; width:100%\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo (Small / Shrunk)</label>\n <div id=\"logo-small-preview-container\" style=\"width:100%; height:75px; margin:8px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:8px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogoSmall ? `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-small-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; gap:6px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo-small\" data-tooltip=\"Upload new small company logo\" data-tooltip-pos=\"top\" style=\"flex:1\">\n <span class=\"material-icons-outlined\" style=\"font-size:14px\">upload</span> Upload\n </button>\n ${pendingLogoSmall ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo-small\" data-tooltip=\"Remove small company logo\" data-tooltip-pos=\"top\" style=\"color:var(--color-danger); padding:0 8px\" title=\"Remove small logo\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>` : ''}\n </div>\n </div>\n\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:4px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\" data-tooltip=\"Save company details permanently\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n const logoSmallInput = tc.querySelector('#logo-small-upload');\n \n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n tc.querySelector('#btn-upload-logo-small').addEventListener('click', () => logoSmallInput.click());\n \n const removeLogoHandler = () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo')?.remove();\n };\n\n const removeLogoSmallHandler = () => {\n pendingLogoSmall = null;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:24px\">image</span>\n <span style=\"font-size:10px; margin-top:2px\">No small logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo-small')?.remove();\n };\n\n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogo = re.target.result;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Large logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoHandler);\n }\n };\n reader.readAsDataURL(file);\n }\n });\n\n logoSmallInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogoSmall = re.target.result;\n const container = tc.querySelector('#logo-small-preview-container');\n container.innerHTML = `<img src=\"${pendingLogoSmall}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Small logo preview updated. Click Save to apply.', 'info');\n \n let removeBtn = tc.querySelector('#btn-remove-logo-small');\n if (!removeBtn) {\n removeBtn = document.createElement('button');\n removeBtn.className = 'btn btn-ghost btn-sm';\n removeBtn.id = 'btn-remove-logo-small';\n removeBtn.style.cssText = 'color:var(--color-danger); padding:0 8px';\n removeBtn.title = 'Remove small logo';\n removeBtn.innerHTML = '<span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span>';\n tc.querySelector('#btn-upload-logo-small').parentNode.appendChild(removeBtn);\n removeBtn.addEventListener('click', removeLogoSmallHandler);\n }\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', removeLogoHandler);\n tc.querySelector('#btn-remove-logo-small')?.addEventListener('click', removeLogoSmallHandler);\n\n tc.querySelector('#btn-save-company').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n settings.logoSmall = pendingLogoSmall;\n \n store.saveSettings(settings);\n showToast('Company information saved permanently', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\" data-tooltip=\"Create a new user account\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Email</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.email || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\" data-tooltip=\"Create a new custom user type / role\" data-tooltip-pos=\"left\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Default Tax Rate (GST)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" type=\"number\" value=\"10\" style=\"width:100px\" disabled /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\" data-tooltip=\"Save tax rates, markup, rounding and profiles\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', () => {\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n store.saveSettings(settings);\n showToast('Financial and Rate settings saved', 'success');\n renderContent();\n });\n\n\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\" data-tooltip=\"Save client portal access rules and messages\" data-tooltip-pos=\"top\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n });\n\n } else if (activeTab === 'system') {\n const s = store.getSettings();\n const currentPref = s.tooltipPreference || 'full';\n tc.innerHTML = `\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:var(--space-lg); max-width:960px; align-items:start;\">\n <!-- Data Management -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\" data-tooltip=\"Restore default sandbox demo database\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\" data-tooltip=\"Permanently delete all local database records\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n ${currentUser.role === 'admin' ? `\n <hr style=\"margin:var(--space-md) 0; border:none; border-top:1px dashed var(--border-color);\" />\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups or seed a single test sample to explore the blank app layout.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" data-tooltip=\"Clean database and seed exactly one sample record of each entity\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed One Example Version\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" data-tooltip=\"Delete all data and return system to a clean blank state\" data-tooltip-pos=\"left\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n </div>\n ` : ''}\n </div>\n </div>\n </div>\n\n <!-- Interface Preferences -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Interface Preferences</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Information Popups (Tooltips)</label>\n <select class=\"form-select\" id=\"tooltip-preference\" style=\"width:100%\">\n <option value=\"full\" ${currentPref === 'full' ? 'selected' : ''}>Full Info (Show all tooltips)</option>\n <option value=\"partial\" ${currentPref === 'partial' ? 'selected' : ''}>Partial Info (Critical & destructive actions only)</option>\n <option value=\"none\" ${currentPref === 'none' ? 'selected' : ''}>No Info (Disable all tooltips)</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px; margin-top:8px; line-height:1.4;\">\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n </p>\n </div>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#tooltip-preference')?.addEventListener('change', (e) => {\n const settings = store.getSettings();\n settings.tooltipPreference = e.target.value;\n store.saveSettings(settings);\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n showToast('Interface preferences saved', 'success');\n });\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a minimal example dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load <strong>exactly one example</strong> of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for quick feature walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed One Example Version\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Example\",\n className: \"btn-primary\",\n onClick: (close) => {\n close();\n seedMinimalData();\n showToast('Single-item example seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', email: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Email</label>\n <input class=\"form-input\" id=\"u-email\" value=\"${t.email || ''}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\">\n <option value=\"\">-- Select --</option>\n ${userTypes.map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('u-name').value;\n const email = document.getElementById('u-email').value;\n const role = document.getElementById('u-role').value;\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('technicians', editId, { name, email, role, userTypeId, color, payRate });\n } else {\n store.create('technicians', { name, email, role, userTypeId, color, payRate });\n }\n showToast('User saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\" data-tooltip=\"Create a new tasklist template to standardize workflows\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:40%\" title=\"${escapeHTML(node.name)}\">Task Details</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span> Duplicate</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n ${!hasSubs ? `\n <div style=\"margin-top:8px; border-top:1px solid var(--border-color); padding-top:16px\">\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px\">\n <div style=\"display:flex; align-items:center; gap:6px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--color-primary)\">assignment</span>\n <span style=\"font-size:13px; font-weight:700; color:var(--text-primary); text-transform:uppercase; letter-spacing:0.3px\">Value Fields</span>\n </div>\n <button class=\"btn btn-sm btn-secondary btn-add-value-field-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Field</button>\n </div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:10px\">Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).</div>\n <div style=\"display:flex; flex-direction:column; gap:8px\" id=\"value-fields-config-tmpl\">\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n <div style=\"padding:10px 12px; background:var(--bg-color); border:1px solid var(--border-color); border-radius:6px\" data-vf-idx=\"${vi}\">\n <div style=\"display:flex; align-items:center; gap:8px; margin-bottom:${ft !== 'text' ? '8px' : '0'}\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-tertiary); cursor:grab\">drag_indicator</span>\n <input type=\"text\" class=\"form-input vf-tmpl-label-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.label)}\" placeholder=\"Field label (e.g. Oil Pressure)\" style=\"flex:2; height:32px; font-size:13px\" />\n <select class=\"form-input vf-tmpl-type-select\" data-vf-idx=\"${vi}\" style=\"flex:0 0 110px; height:32px; font-size:12px\">\n <option value=\"text\"${ft === 'text' ? ' selected' : ''}>Text</option>\n <option value=\"number\"${ft === 'number' ? ' selected' : ''}>Number</option>\n <option value=\"dropdown\"${ft === 'dropdown' ? ' selected' : ''}>Dropdown</option>\n </select>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-remove-value-field-tmpl\" data-vf-idx=\"${vi}\" style=\"color:var(--color-danger); min-width:28px; min-height:28px; padding:0\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">close</span></button>\n </div>\n ${ft === 'number' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n <div style=\"display:flex; align-items:center; gap:4px; flex:2\">\n <span style=\"font-size:11px; color:var(--text-tertiary); white-space:nowrap\">Range:</span>\n <input type=\"number\" class=\"form-input vf-tmpl-min-input\" data-vf-idx=\"${vi}\" value=\"${vf.min !== undefined ? vf.min : ''}\" placeholder=\"Min\" style=\"flex:1; height:30px; font-size:12px\" />\n <span style=\"color:var(--text-tertiary)\">–</span>\n <input type=\"number\" class=\"form-input vf-tmpl-max-input\" data-vf-idx=\"${vi}\" value=\"${vf.max !== undefined ? vf.max : ''}\" placeholder=\"Max\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n </div>\n ` : ''}\n ${ft === 'text' ? `\n <div style=\"display:flex; align-items:center; gap:8px; margin-left:28px; margin-top:4px\">\n <input type=\"text\" class=\"form-input vf-tmpl-unit-input\" data-vf-idx=\"${vi}\" value=\"${escapeHTML(vf.unit || '')}\" placeholder=\"Unit (optional, e.g. PSI)\" style=\"flex:1; height:30px; font-size:12px\" />\n </div>\n ` : ''}\n ${ft === 'dropdown' ? `\n <div style=\"margin-left:28px; display:flex; flex-direction:column; gap:6px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Options (one per line)</div>\n <textarea class=\"form-input vf-tmpl-options-input\" data-vf-idx=\"${vi}\" rows=\"3\" placeholder=\"Low\\nAs Expected\\nHigh\" style=\"font-size:12px; line-height:1.5\">${escapeHTML((vf.options || []).join('\\n'))}</textarea>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:4px\">Expected / Ideal Value <span style=\"font-weight:400\">(flags others as out of range)</span></div>\n <select class=\"form-input vf-tmpl-expected-select\" data-vf-idx=\"${vi}\" style=\"height:30px; font-size:12px\">\n <option value=\"\"${!vf.expectedValue ? ' selected' : ''}>— No expected value —</option>\n ${(vf.options || []).map(opt => `<option value=\"${escapeHTML(opt)}\"${vf.expectedValue === opt ? ' selected' : ''}>${escapeHTML(opt)}</option>`).join('')}\n </select>\n </div>\n </div>\n ` : ''}\n </div>`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '<div style=\"color:var(--text-tertiary); font-size:12px; text-align:center; padding:16px; border:1px dashed var(--border-color); border-radius:6px\">No value fields defined. Click \"Add Field\" to create one.</div>' : ''}\n </div>\n </div>\n ` : ''}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n valueFields: node.valueFields ? node.valueFields.map(vf => ({ ...vf })) : undefined,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 11. Value Fields interactive configuration listeners\n content.querySelector('.btn-add-value-field-tmpl')?.addEventListener('click', () => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n if (!node.valueFields) node.valueFields = [];\n node.valueFields.push({ id: store.generateId(), label: '', unit: '', value: '', fieldType: 'text' });\n renderTemplateEditor();\n });\n\n content.querySelectorAll('.btn-remove-value-field-tmpl').forEach(btn => {\n btn.addEventListener('click', () => {\n const idx = parseInt(btn.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node || !node.valueFields) return;\n node.valueFields.splice(idx, 1);\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-label-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].label = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-unit-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].unit = inp.value.trim();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-type-select').forEach(sel => {\n sel.addEventListener('change', () => {\n const idx = parseInt(sel.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].fieldType = sel.value;\n if (sel.value !== 'number') { node.valueFields[idx].min = undefined; node.valueFields[idx].max = undefined; }\n if (sel.value !== 'dropdown') { node.valueFields[idx].options = undefined; }\n if (sel.value === 'dropdown') { node.valueFields[idx].unit = ''; }\n node.valueFields[idx].value = '';\n }\n renderTemplateEditor();\n });\n });\n\n content.querySelectorAll('.vf-tmpl-min-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].min = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-max-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].max = inp.value !== '' ? parseFloat(inp.value) : undefined;\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-options-input').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].options = inp.value.split('\\n').map(o => o.trim()).filter(Boolean);\n renderTemplateEditor();\n }\n });\n });\n\n content.querySelectorAll('.vf-tmpl-expected-select').forEach(inp => {\n inp.addEventListener('change', () => {\n const idx = parseInt(inp.dataset.vfIdx);\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (node && node.valueFields && node.valueFields[idx]) {\n node.valueFields[idx].expectedValue = inp.value || undefined;\n }\n });\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-xl',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\" data-tooltip=\"Create a new quote template to speed up quoting\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\" data-tooltip=\"Save material markup rules and categories\" data-tooltip-pos=\"top\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = () => {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n store.saveSettings(updatedSettings);\n showToast('Material settings saved', 'success');\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\" data-tooltip=\"Create a new form template for safety checks and inspections\" data-tooltip-pos=\"left\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n function renderInvoicesQuotesTab(tc) {\n const settings = store.getSettings();\n let dt = JSON.parse(JSON.stringify(settings.documentTheme || {}));\n let activePreviewTab = 'invoice';\n\n const mockData = {\n number: 'INV-10023',\n status: 'Sent',\n customerName: 'Acme Developments Pty Ltd',\n contactName: 'Jane Smith',\n issueDate: new Date().toISOString(),\n validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),\n dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),\n jobNumber: 'J-88129',\n originalQuoteNumber: 'Q-54412',\n title: 'Emergency Electrical & Cabling Installation',\n notes: 'Please review all measurements. Standard technician notes and variations have been compiled accordingly.',\n subtotal: 1250.00,\n tax: 125.00,\n total: 1375.00,\n lineItems: [\n { description: 'Emergency Call-out Service Rate', type: 'labor', qty: 1, rate: 195.00, total: 195.00 },\n { description: 'Electrical Cabling Installation & Termination', type: 'labor', qty: 4, rate: 85.00, total: 340.00 },\n { description: 'Premium Circuit Breakers & Switchboards', type: 'material', qty: 3, rate: 150.00, total: 450.00 },\n { description: 'Sundry Fixings, Consumables & Conduit', type: 'material', qty: 1, rate: 265.00, total: 265.00 }\n ]\n };\n\n const presets = {\n relay: { name: 'Relay Dispatch', preset: 'relay', accentColor: '#1B6DE0', headerBg: '#1E2A3A', accentTint: '#F8FAFC', fontFamily: 'sans-serif' },\n classic: { name: 'Classic Corporate', preset: 'classic', accentColor: '#0F172A', headerBg: '#1E293B', accentTint: '#F8FAFC', fontFamily: 'serif' },\n forest: { name: 'Forest Minimalist', preset: 'forest', accentColor: '#16A34A', headerBg: '#1F2937', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n electric: { name: 'Vibrant Electric', preset: 'electric', accentColor: '#7C3AED', headerBg: '#111827', accentTint: '#F9FAFB', fontFamily: 'sans-serif' },\n obsidian: { name: 'Sleek Obsidian', preset: 'obsidian', accentColor: '#1E293B', headerBg: '#0F172A', accentTint: '#FAFBFB', fontFamily: 'monospace' },\n terracotta: { name: 'Sunset Terracotta', preset: 'terracotta', accentColor: '#C2410C', headerBg: '#451A03', accentTint: '#FFF7ED', fontFamily: 'sans-serif' },\n nordic: { name: 'Nordic Frost', preset: 'nordic', accentColor: '#0891B2', headerBg: '#0F172A', accentTint: '#F0FDFA', fontFamily: 'sans-serif' },\n luxury: { name: 'Royal Velvet', preset: 'luxury', accentColor: '#D97706', headerBg: '#311005', accentTint: '#FEF3C7', fontFamily: 'serif' },\n steel: { name: 'Steel Industrial', preset: 'steel', accentColor: '#475569', headerBg: '#1E293B', accentTint: '#F1F5F9', fontFamily: 'monospace' }\n };\n\n function renderMarkup() {\n tc.innerHTML = `\n <style>\n .preset-card:hover {\n border-color: var(--color-primary-light) !important;\n background: var(--bg-color) !important;\n }\n .preset-card.active {\n border-color: var(--color-primary) !important;\n background: var(--color-primary-light) !important;\n color: var(--color-primary) !important;\n }\n </style>\n <div style=\"display:grid; grid-template-columns: 460px 1fr; gap:var(--space-lg); min-height:calc(100vh - 200px); align-items:stretch\">\n \n <!-- Control Panel -->\n <div style=\"display:flex; flex-direction:column; gap:16px; overflow-y:auto; padding-right:8px; max-height:calc(100vh - 200px)\">\n \n <!-- Preset Themes -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Choose Theme Preset</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:8px\">\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n <div class=\"preset-card ${isActive ? 'active' : ''}\" data-preset=\"${key}\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${isActive ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <div style=\"width:16px; height:16px; border-radius:50%; background:${val.accentColor}\"></div>\n <span style=\"font-weight:600\">${val.name}</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">${val.fontFamily}</span>\n </div>\n `;\n }).join('')}\n <div class=\"preset-card ${dt.preset === 'custom' ? 'active' : ''}\" data-preset=\"custom\" style=\"\n display:flex; justify-content:space-between; align-items:center; \n padding:12px 16px; border:2px solid ${dt.preset === 'custom' ? 'var(--color-primary)' : 'var(--border-color)'}; \n border-radius:8px; cursor:pointer; background:var(--card-bg); transition:all 0.2s;\n \">\n <div style=\"display:flex; align-items:center; gap:10px\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px; color:var(--text-secondary)\">palette</span>\n <span style=\"font-weight:600\">Custom Styling</span>\n </div>\n <span style=\"font-size:12px; color:var(--text-tertiary)\">Full control</span>\n </div>\n </div>\n </div>\n\n <!-- Custom Styles (only active/visible if preset is Custom) -->\n <div class=\"card\" id=\"custom-style-controls\" style=\"display:${dt.preset === 'custom' ? 'block' : 'none'}\">\n <div class=\"card-header\"><h4>Custom Color & Typography</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Primary Accent Color</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentColor\" value=\"${dt.accentColor}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-accent-text\" value=\"${dt.accentColor}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Table Header Fill</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"headerBg\" value=\"${dt.headerBg}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-header-text\" value=\"${dt.headerBg}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Accent Tint Background</label>\n <div style=\"display:flex; gap:8px\">\n <input type=\"color\" class=\"form-input style-input\" data-prop=\"accentTint\" value=\"${dt.accentTint}\" style=\"width:48px; height:38px; padding:2px; cursor:pointer\" />\n <input type=\"text\" class=\"form-input style-text\" id=\"color-tint-text\" value=\"${dt.accentTint}\" style=\"flex:1\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Typography Font</label>\n <select class=\"form-select style-input\" data-prop=\"fontFamily\">\n <option value=\"sans-serif\" ${dt.fontFamily === 'sans-serif' ? 'selected' : ''}>Sans-Serif (Modern / Inter & Outfit)</option>\n <option value=\"serif\" ${dt.fontFamily === 'serif' ? 'selected' : ''}>Serif (Prestige / Georgia & Lora)</option>\n <option value=\"monospace\" ${dt.fontFamily === 'monospace' ? 'selected' : ''}>Monospace (Industrial / Fira Code)</option>\n </select>\n </div>\n </div>\n </div>\n\n <!-- Branding / Logo layout -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Logo Branding & Scaling</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-hide-logo\" style=\"width:16px; height:16px\" ${dt.hideLogo ? 'checked' : ''} />\n <label for=\"theme-hide-logo\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Hide logo image (show styled text header)</label>\n </div>\n <div id=\"logo-branding-controls\" style=\"display:${dt.hideLogo ? 'none' : 'flex'}; flex-direction:column; gap:12px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Alignment</label>\n <div style=\"display:flex; gap:4px\">\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'left' ? 'btn-primary' : ''}\" data-align=\"left\" style=\"flex:1\">Left</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'center' ? 'btn-primary' : ''}\" data-align=\"center\" style=\"flex:1\">Center</button>\n <button class=\"btn btn-secondary btn-sm logo-align-btn ${dt.logoAlignment === 'right' ? 'btn-primary' : ''}\" data-align=\"right\" style=\"flex:1\">Right</button>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Logo Scale (Height): <span id=\"scale-val-label\">${dt.logoScale || 60}px</span></label>\n <input type=\"range\" id=\"theme-logo-scale\" min=\"40\" max=\"120\" value=\"${dt.logoScale || 60}\" style=\"width:100%\" />\n </div>\n </div>\n </div>\n </div>\n\n <!-- Document Text Custom Copy -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Custom Copy & Titles</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:12px\">\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:4px\">Invoice Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"invoiceTitle\" value=\"${escapeHTML(dt.invoiceTitle || 'TAX INVOICE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Direct Deposit Bank Account Details</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoicePaymentTerms\" rows=\"3\" placeholder=\"BSB: ...\\nAccount: ...\">${escapeHTML(dt.invoicePaymentTerms || '')}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Invoice Legal Terms / Footer Memo</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"invoiceTerms\" rows=\"2\" placeholder=\"Payment is due...\">${escapeHTML(dt.invoiceTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Quote Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Document Title</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"quoteTitle\" value=\"${escapeHTML(dt.quoteTitle || 'PROPOSAL / QUOTE')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Quote Validity / General Terms</label>\n <textarea class=\"form-input text-copy-input\" data-prop=\"quoteTerms\" rows=\"2\" placeholder=\"Quote valid for...\">${escapeHTML(dt.quoteTerms || '')}</textarea>\n </div>\n\n <div style=\"font-weight:700; font-size:12px; color:var(--color-primary); text-transform:uppercase; border-bottom:1px solid var(--border-color); padding-bottom:4px; margin-top:8px\">Global Options</div>\n <div class=\"form-group\">\n <label class=\"form-label\">Footer Signature / Compliance Note</label>\n <input type=\"text\" class=\"form-input text-copy-input\" data-prop=\"footerNote\" value=\"${escapeHTML(dt.footerNote || '')}\" placeholder=\"Thank you for your business...\" />\n </div>\n </div>\n </div>\n\n <!-- Features & Integration Toggles -->\n <div class=\"card\">\n <div class=\"card-header\"><h4>Features & Integrations</h4></div>\n <div class=\"card-body\" style=\"padding:16px; display:flex; flex-direction:column; gap:10px\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-stripe\" style=\"width:16px; height:16px\" ${dt.paymentStripe ? 'checked' : ''} />\n <label for=\"theme-stripe\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Credit Cards (Include online Stripe pay link)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-bank-transfer\" style=\"width:16px; height:16px\" ${dt.paymentDirectTransfer ? 'checked' : ''} />\n <label for=\"theme-bank-transfer\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Show Bank Details (Direct Bank Transfers)</label>\n </div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-cash\" style=\"width:16px; height:16px\" ${dt.paymentCash ? 'checked' : ''} />\n <label for=\"theme-cash\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Accept Cash payment option text</label>\n </div>\n <div style=\"height:1px; background:var(--border-color); margin:4px 0\"></div>\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:8px\">\n <input type=\"checkbox\" id=\"theme-quote-sig\" style=\"width:16px; height:16px\" ${dt.quoteSignature ? 'checked' : ''} />\n <label for=\"theme-quote-sig\" class=\"form-label\" style=\"margin:0; cursor:pointer\">Include customer sign-off signature blocks on Quotes</label>\n </div>\n </div>\n </div>\n\n <!-- Action buttons -->\n <div style=\"display:flex; gap:12px; margin-top:4px; padding-bottom:24px\">\n <button class=\"btn btn-secondary\" id=\"btn-theme-reset\" style=\"flex:1\">Reset Defaults</button>\n <button class=\"btn btn-primary\" id=\"btn-theme-save\" style=\"flex:1\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">save</span> Save Changes</button>\n </div>\n\n </div>\n\n <!-- Sticky Live Preview -->\n <div style=\"display:flex; flex-direction:column; gap:12px; height:calc(100vh - 200px); position:sticky; top:20px;\">\n <div style=\"display:flex; justify-content:space-between; align-items:center;\">\n <span style=\"font-size:13px; font-weight:700; color:var(--text-secondary); text-transform:uppercase\">Live Layout Preview</span>\n <div class=\"tabs\" style=\"margin:0; background:var(--border-color); padding:2px; border-radius:6px; display:inline-flex; gap:2px\">\n <button class=\"btn btn-sm ${activePreviewTab === 'invoice' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-invoice\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Tax Invoice</button>\n <button class=\"btn btn-sm ${activePreviewTab === 'quote' ? 'btn-primary' : 'btn-ghost'}\" id=\"preview-tab-quote\" style=\"padding:6px 12px; border:none; border-radius:4px; font-size:12px; cursor:pointer\">Quote / Proposal</button>\n </div>\n </div>\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:8px; overflow:hidden; background:#f1f5f9; position:relative; box-shadow:inset 0 4px 10px rgba(0,0,0,0.05)\">\n <iframe id=\"preview-frame\" style=\"width:100%; height:100%; border:none;\"></iframe>\n </div>\n </div>\n\n </div>\n `;\n\n bindListeners();\n updatePreview();\n }\n\n function bindListeners() {\n // Preset selection click\n tc.querySelectorAll('.preset-card').forEach(card => {\n card.addEventListener('click', () => {\n const key = card.dataset.preset;\n if (key === 'custom') {\n dt.preset = 'custom';\n } else {\n const p = presets[key];\n dt.preset = key;\n dt.accentColor = p.accentColor;\n dt.headerBg = p.headerBg;\n dt.accentTint = p.accentTint;\n dt.fontFamily = p.fontFamily;\n }\n renderMarkup();\n });\n });\n\n // Custom style hex text / inputs\n tc.querySelectorAll('.style-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const prop = e.target.dataset.prop;\n dt[prop] = e.target.value;\n \n if (prop === 'accentColor') tc.querySelector('#color-accent-text').value = e.target.value;\n if (prop === 'headerBg') tc.querySelector('#color-header-text').value = e.target.value;\n if (prop === 'accentTint') tc.querySelector('#color-tint-text').value = e.target.value;\n \n updatePreview();\n });\n });\n\n tc.querySelectorAll('.style-text').forEach(txt => {\n txt.addEventListener('change', (e) => {\n const val = e.target.value;\n if (/^#[0-9A-F]{6}$/i.test(val)) {\n if (e.target.id === 'color-accent-text') {\n dt.accentColor = val;\n tc.querySelector('[data-prop=\"accentColor\"]').value = val;\n } else if (e.target.id === 'color-header-text') {\n dt.headerBg = val;\n tc.querySelector('[data-prop=\"headerBg\"]').value = val;\n } else if (e.target.id === 'color-tint-text') {\n dt.accentTint = val;\n tc.querySelector('[data-prop=\"accentTint\"]').value = val;\n }\n updatePreview();\n }\n });\n });\n\n // Logo Align\n tc.querySelectorAll('.logo-align-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n dt.logoAlignment = btn.dataset.align;\n tc.querySelectorAll('.logo-align-btn').forEach(b => b.classList.remove('btn-primary'));\n btn.classList.add('btn-primary');\n updatePreview();\n });\n });\n\n // Hide Logo toggle\n const hideLogoChk = tc.querySelector('#theme-hide-logo');\n hideLogoChk.addEventListener('change', () => {\n dt.hideLogo = hideLogoChk.checked;\n tc.querySelector('#logo-branding-controls').style.display = dt.hideLogo ? 'none' : 'flex';\n updatePreview();\n });\n\n // Scale slider\n const scaleSlider = tc.querySelector('#theme-logo-scale');\n scaleSlider.addEventListener('input', () => {\n dt.logoScale = parseInt(scaleSlider.value);\n tc.querySelector('#scale-val-label').textContent = dt.logoScale + 'px';\n updatePreview();\n });\n\n // Text copy updates\n tc.querySelectorAll('.text-copy-input').forEach(inp => {\n inp.addEventListener('input', () => {\n const prop = inp.dataset.prop;\n dt[prop] = inp.value;\n updatePreview();\n });\n });\n\n // Features integration switches\n tc.querySelector('#theme-stripe').addEventListener('change', (e) => {\n dt.paymentStripe = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-bank-transfer').addEventListener('change', (e) => {\n dt.paymentDirectTransfer = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-cash').addEventListener('change', (e) => {\n dt.paymentCash = e.target.checked;\n updatePreview();\n });\n tc.querySelector('#theme-quote-sig').addEventListener('change', (e) => {\n dt.quoteSignature = e.target.checked;\n updatePreview();\n });\n\n // Invoice / Quote Preview tabs\n tc.querySelector('#preview-tab-invoice').addEventListener('click', () => {\n activePreviewTab = 'invoice';\n tc.querySelector('#preview-tab-invoice').classList.add('btn-primary');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-primary');\n updatePreview();\n });\n tc.querySelector('#preview-tab-quote').addEventListener('click', () => {\n activePreviewTab = 'quote';\n tc.querySelector('#preview-tab-quote').classList.add('btn-primary');\n tc.querySelector('#preview-tab-quote').classList.remove('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.add('btn-ghost');\n tc.querySelector('#preview-tab-invoice').classList.remove('btn-primary');\n updatePreview();\n });\n\n // Save Theme Settings\n tc.querySelector('#btn-theme-save').addEventListener('click', () => {\n const freshSettings = store.getSettings();\n freshSettings.documentTheme = { ...dt };\n store.saveSettings(freshSettings);\n showToast('Document customization settings saved permanently', 'success');\n });\n\n // Reset Theme Settings\n tc.querySelector('#btn-theme-reset').addEventListener('click', () => {\n if (confirm('Are you sure you want to reset documents customization to the factory defaults?')) {\n dt = {\n preset: 'relay',\n accentColor: '#1B6DE0',\n headerBg: '#1E2A3A',\n accentTint: '#F8FAFC',\n fontFamily: 'sans-serif',\n invoiceTitle: 'TAX INVOICE',\n invoiceTerms: 'Please pay within 7 days of invoice issue.',\n invoicePaymentTerms: 'Payment via Direct Deposit:\\nBSB: 123-456\\nAccount: 78901234\\nReference: [Invoice Number]',\n quoteTitle: 'PROPOSAL / QUOTE',\n quoteTerms: 'This quote is valid for 30 days. All work is subject to standard conditions.',\n logoAlignment: 'left',\n logoScale: 60,\n hideLogo: false,\n paymentStripe: true,\n paymentDirectTransfer: true,\n paymentCash: false,\n quoteSignature: true,\n footerNote: 'Thank you for your business!'\n };\n renderMarkup();\n }\n });\n }\n\n function updatePreview() {\n const frame = tc.querySelector('#preview-frame');\n if (!frame) return;\n \n const tempSettings = {\n ...settings,\n documentTheme: { ...dt }\n };\n\n const html = `\n <!DOCTYPE html>\n <html>\n <head>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\" />\n <style>\n ${getPrintStyles(tempSettings)}\n /* Overwrite general frame layout margins */\n .pdf-page { padding: 24px 32px !important; max-width: 100% !important; margin: 0 !important; }\n </style>\n </head>\n <body style=\"background:#f1f5f9; padding: 16px; margin:0\">\n <div style=\"background:white; border-radius:6px; border:1px solid #e2e8f0; overflow:hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1)\">\n ${generateDocument(activePreviewTab, mockData)}\n </div>\n </body>\n </html>\n `;\n\n try {\n const doc = frame.contentDocument || frame.contentWindow.document;\n doc.open();\n doc.write(html);\n doc.close();\n } catch (err) {\n console.error('Mock preview document injection error:', err);\n }\n }\n\n renderMarkup();\n }\n\n\n"}, "4c3aff51a495a10bb8de8313c5a4078c1bf8c71c": {"t": 1785754213.2460277, "n": 115404, "h": "5eef74f364e444ea", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData } from '../data/seed.js';\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'assets' ? 'active' : ''}\" data-tab=\"assets\">Assets</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''}\" data-tab=\"portal\">Customer Portal</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'Apex Power Services'}\" id=\"company-name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || '51 234 567 890'}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || '(02) 6882 4400'}\" />\n </div>\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.domain || 'apexpowerservices.com.au'}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Email</label>\n <input class=\"form-input\" value=\"${s.email || 'admin@apexpowerservices.com.au'}\" id=\"company-email\" placeholder=\"e.g. admin@yourcompany.com.au\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\">${s.address || '14 Yarrandale Rd, Dubbo NSW 2830'}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; align-items:center; text-align:center\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo</label>\n <div id=\"logo-preview-container\" style=\"width:100%; aspect-ratio:1; margin:12px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:12px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:48px\">image</span>\n <span style=\"font-size:12px; margin-top:8px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; flex-direction:column; gap:8px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" style=\"width:100%\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">upload</span> Upload Logo\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" style=\"color:var(--color-danger); width:100%\">Remove Logo</button>` : ''}\n </div>\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:8px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n \n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogo = re.target.result;\n // Update ONLY the preview area and show hint\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Logo preview updated. Click Save to apply.', 'info');\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:48px\">image</span>\n <span style=\"font-size:12px; margin-top:8px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo').style.display = 'none';\n });\n\n tc.querySelector('#btn-save-company').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.domain = tc.querySelector('#company-domain').value;\n settings.email = tc.querySelector('#company-email').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n \n store.saveSettings(settings);\n showToast('Company information saved permanently', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Email</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.email || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Default Tax Rate (GST)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" type=\"number\" value=\"10\" style=\"width:100px\" disabled /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', () => {\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n store.saveSettings(settings);\n showToast('Financial and Rate settings saved', 'success');\n renderContent();\n });\n\n } else if (activeTab === 'assets') {\n const settings = store.getSettings();\n const assets = store.getAll('assets').filter(a => a.category === 'Business');\n \n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Business Asset Defaults</h4>\n <div class=\"badge badge-info\">Recovery Automation Enabled</div>\n </div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px; margin-bottom:20px\">Configure the default recovery rates for your business equipment. These rates are used to calculate internal job costs when assets are assigned to tasks.</p>\n \n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Asset Name</th>\n <th>Current Rate ($/hr)</th>\n <th>Status</th>\n </tr>\n </thead>\n <tbody>\n ${assets.map(a => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(a.name)}</td>\n <td>\n <div style=\"display:flex; align-items:center; gap:8px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input asset-rate-input\" data-id=\"${a.id}\" value=\"${a.recoveryRate || 0}\" step=\"0.5\" style=\"width:100px; height:32px\" />\n </div>\n </td>\n <td><span class=\"badge badge-success\">Active</span></td>\n </tr>\n `).join('')}\n ${assets.length === 0 ? '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:24px\">No business assets found. Add assets in the main Assets module.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-asset-settings\">Save Asset Recovery Rates</button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-asset-settings').addEventListener('click', () => {\n tc.querySelectorAll('.asset-rate-input').forEach(inp => {\n const id = inp.dataset.id;\n const rate = parseFloat(inp.value) || 0;\n store.update('assets', id, { recoveryRate: rate });\n });\n showToast('Asset recovery rates updated across the system', 'success');\n });\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n });\n\n } else if (activeTab === 'system') {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:480px\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n ${currentUser.role === 'admin' ? `\n <hr style=\"margin:var(--space-md) 0; border:none; border-top:1px dashed var(--border-color);\" />\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups or seed a single test sample to explore the blank app layout.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed One Example Version\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n </div>\n ` : ''}\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a minimal example dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load <strong>exactly one example</strong> of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for quick feature walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed One Example Version\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Example\",\n className: \"btn-primary\",\n onClick: (close) => {\n close();\n seedMinimalData();\n showToast('Single-item example seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', email: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Email</label>\n <input class=\"form-input\" id=\"u-email\" value=\"${t.email || ''}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\">\n <option value=\"\">-- Select --</option>\n ${userTypes.map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('u-name').value;\n const email = document.getElementById('u-email').value;\n const role = document.getElementById('u-role').value;\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('technicians', editId, { name, email, role, userTypeId, color, payRate });\n } else {\n store.create('technicians', { name, email, role, userTypeId, color, payRate });\n }\n showToast('User saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n ${!isInfoPanelEditing ? `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:60%\" title=\"${escapeHTML(node.name)}\">${escapeHTML(node.name)}</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-primary btn-edit-info-tmpl\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">edit</span> Edit</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Name</div>\n <div style=\"font-size:14px; font-weight:500\">${escapeHTML(node.name)}</div>\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:14px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div style=\"display:flex; gap:16px; margin-bottom:12px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Estimated Hours</div>\n <div style=\"font-size:14px; font-weight:500\">${node.estimatedHours || 0} hrs</div>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">People</div>\n <div style=\"font-size:14px; font-weight:500\">${node.people || 1}</div>\n </div>\n </div>\n `}\n <div style=\"margin-top:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Description</div>\n <div style=\"font-size:13px; white-space:pre-wrap; color:var(--text-secondary)\">${escapeHTML(node.description || 'No description provided.')}</div>\n </div>\n ` : `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0\">Edit Item Details</h4>\n <div style=\"display:flex; gap:6px\">\n <button class=\"btn btn-xs btn-primary btn-done-info-tmpl\">Done</button>\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span></button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:0\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n `}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-lg',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = () => {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n store.saveSettings(updatedSettings);\n showToast('Material settings saved', 'success');\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n\n"}, "76ee7468d3065c16698484b4e527e70266668863": {"t": 1785754214.8778453, "n": 114942, "h": "5921cd48f029c78b", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData } from '../data/seed.js';\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'assets' ? 'active' : ''}\" data-tab=\"assets\">Assets</button>\n <button class=\"tab ${activeTab === 'portal' ? 'active' : ''}\" data-tab=\"portal\">Customer Portal</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'FieldForge Demo Company'}\" id=\"company-name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || '12 345 678 901'}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || '1300 123 456'}\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.email || 'fieldforge.io'}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\">${s.address || '123 Business St, Melbourne VIC 3000'}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; align-items:center; text-align:center\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo</label>\n <div id=\"logo-preview-container\" style=\"width:100%; aspect-ratio:1; margin:12px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:12px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:48px\">image</span>\n <span style=\"font-size:12px; margin-top:8px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; flex-direction:column; gap:8px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" style=\"width:100%\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">upload</span> Upload Logo\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" style=\"color:var(--color-danger); width:100%\">Remove Logo</button>` : ''}\n </div>\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:8px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n \n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogo = re.target.result;\n // Update ONLY the preview area and show hint\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Logo preview updated. Click Save to apply.', 'info');\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:48px\">image</span>\n <span style=\"font-size:12px; margin-top:8px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo').style.display = 'none';\n });\n\n tc.querySelector('#btn-save-company').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.email = tc.querySelector('#company-domain').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n \n store.saveSettings(settings);\n showToast('Company information saved permanently', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Email</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.email || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Default Tax Rate (GST)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" type=\"number\" value=\"10\" style=\"width:100px\" disabled /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', () => {\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n store.saveSettings(settings);\n showToast('Financial and Rate settings saved', 'success');\n renderContent();\n });\n\n } else if (activeTab === 'assets') {\n const settings = store.getSettings();\n const assets = store.getAll('assets').filter(a => a.category === 'Business');\n \n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Business Asset Defaults</h4>\n <div class=\"badge badge-info\">Recovery Automation Enabled</div>\n </div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px; margin-bottom:20px\">Configure the default recovery rates for your business equipment. These rates are used to calculate internal job costs when assets are assigned to tasks.</p>\n \n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Asset Name</th>\n <th>Current Rate ($/hr)</th>\n <th>Status</th>\n </tr>\n </thead>\n <tbody>\n ${assets.map(a => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(a.name)}</td>\n <td>\n <div style=\"display:flex; align-items:center; gap:8px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input asset-rate-input\" data-id=\"${a.id}\" value=\"${a.recoveryRate || 0}\" step=\"0.5\" style=\"width:100px; height:32px\" />\n </div>\n </td>\n <td><span class=\"badge badge-success\">Active</span></td>\n </tr>\n `).join('')}\n ${assets.length === 0 ? '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:24px\">No business assets found. Add assets in the main Assets module.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-asset-settings\">Save Asset Recovery Rates</button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-asset-settings').addEventListener('click', () => {\n tc.querySelectorAll('.asset-rate-input').forEach(inp => {\n const id = inp.dataset.id;\n const rate = parseFloat(inp.value) || 0;\n store.update('assets', id, { recoveryRate: rate });\n });\n showToast('Asset recovery rates updated across the system', 'success');\n });\n\n } else if (activeTab === 'portal') {\n const s = store.getSettings();\n const portalEnabled = s.enableCustomerPortal !== false;\n const portalWelcome = s.customerPortalWelcome || 'Welcome to your secure customer dashboard. Here you can track your service dispatches, check maintenance, approve quotes, and manage your invoices.';\n const portalPayment = s.customerPortalPayment || 'Please pay via Bank Transfer to BSB: 123-456 Account: 7890 1234. Please quote your Invoice Number as reference.';\n\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Customer Portal Settings</h4></div>\n <div class=\"card-body\" style=\"display:flex; flex-direction:column; gap:20px;\">\n <div class=\"form-group\" style=\"display:flex; align-items:center; gap:12px; background:var(--content-bg); padding:16px; border-radius:8px; border:1px solid var(--border-color)\">\n <input type=\"checkbox\" id=\"portal-enable\" class=\"form-checkbox\" style=\"width:20px; height:20px; cursor:pointer;\" ${portalEnabled ? 'checked' : ''} />\n <div style=\"cursor:pointer;\" onclick=\"document.getElementById('portal-enable').click()\">\n <strong style=\"display:block; font-size:14px; color:var(--text-primary);\">Enable Customer Portal Link Access</strong>\n <span style=\"font-size:12px; color:var(--text-secondary);\">When disabled, any attempt to visit a customer portal link will show an access restricted notice.</span>\n </div>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Custom Portal Welcome Message</label>\n <textarea class=\"form-textarea\" id=\"portal-welcome\" rows=\"3\" placeholder=\"Enter a custom message displayed to customers on their dashboard...\">${escapeHTML(portalWelcome)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">This message will appear prominently at the top of the customer's portal dashboard.</p>\n </div>\n\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-weight:600;\">Invoice Payment Instructions</label>\n <textarea class=\"form-textarea\" id=\"portal-payment\" rows=\"3\" placeholder=\"BSB, Account Number, and payment instructions...\">${escapeHTML(portalPayment)}</textarea>\n <p class=\"text-tertiary\" style=\"font-size:11px; margin-top:4px;\">These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.</p>\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex; justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-portal-settings\">\n <span class=\"material-icons-outlined\">save</span> Save Portal Settings\n </button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-portal-settings').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.enableCustomerPortal = tc.querySelector('#portal-enable').checked;\n settings.customerPortalWelcome = tc.querySelector('#portal-welcome').value.trim();\n settings.customerPortalPayment = tc.querySelector('#portal-payment').value.trim();\n\n store.saveSettings(settings);\n showToast('Customer portal settings saved successfully', 'success');\n });\n\n } else if (activeTab === 'system') {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:480px\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n ${currentUser.role === 'admin' ? `\n <hr style=\"margin:var(--space-md) 0; border:none; border-top:1px dashed var(--border-color);\" />\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups or seed a single test sample to explore the blank app layout.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed One Example Version\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n </div>\n ` : ''}\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a minimal example dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load <strong>exactly one example</strong> of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for quick feature walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed One Example Version\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Example\",\n className: \"btn-primary\",\n onClick: (close) => {\n close();\n seedMinimalData();\n showToast('Single-item example seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', email: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Email</label>\n <input class=\"form-input\" id=\"u-email\" value=\"${t.email || ''}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\">\n <option value=\"\">-- Select --</option>\n ${userTypes.map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('u-name').value;\n const email = document.getElementById('u-email').value;\n const role = document.getElementById('u-role').value;\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('technicians', editId, { name, email, role, userTypeId, color, payRate });\n } else {\n store.create('technicians', { name, email, role, userTypeId, color, payRate });\n }\n showToast('User saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n ${!isInfoPanelEditing ? `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:60%\" title=\"${escapeHTML(node.name)}\">${escapeHTML(node.name)}</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-primary btn-edit-info-tmpl\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">edit</span> Edit</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Name</div>\n <div style=\"font-size:14px; font-weight:500\">${escapeHTML(node.name)}</div>\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:14px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div style=\"display:flex; gap:16px; margin-bottom:12px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Estimated Hours</div>\n <div style=\"font-size:14px; font-weight:500\">${node.estimatedHours || 0} hrs</div>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">People</div>\n <div style=\"font-size:14px; font-weight:500\">${node.people || 1}</div>\n </div>\n </div>\n `}\n <div style=\"margin-top:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Description</div>\n <div style=\"font-size:13px; white-space:pre-wrap; color:var(--text-secondary)\">${escapeHTML(node.description || 'No description provided.')}</div>\n </div>\n ` : `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0\">Edit Item Details</h4>\n <div style=\"display:flex; gap:6px\">\n <button class=\"btn btn-xs btn-primary btn-done-info-tmpl\">Done</button>\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span></button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:0\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n `}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-lg',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = () => {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n store.saveSettings(updatedSettings);\n showToast('Material settings saved', 'success');\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n\n"}, "a896b99c6f8548bcb34d14e18748cfadc391e8de": {"t": 1785754216.9957385, "n": 111355, "h": "af219818e2bb4180", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData } from '../data/seed.js';\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'assets' ? 'active' : ''}\" data-tab=\"assets\">Assets</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'FieldForge Demo Company'}\" id=\"company-name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || '12 345 678 901'}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || '1300 123 456'}\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.email || 'fieldforge.io'}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\">${s.address || '123 Business St, Melbourne VIC 3000'}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; align-items:center; text-align:center\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo</label>\n <div id=\"logo-preview-container\" style=\"width:100%; aspect-ratio:1; margin:12px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:12px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:48px\">image</span>\n <span style=\"font-size:12px; margin-top:8px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; flex-direction:column; gap:8px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" style=\"width:100%\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">upload</span> Upload Logo\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" style=\"color:var(--color-danger); width:100%\">Remove Logo</button>` : ''}\n </div>\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:8px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n \n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogo = re.target.result;\n // Update ONLY the preview area and show hint\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Logo preview updated. Click Save to apply.', 'info');\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:48px\">image</span>\n <span style=\"font-size:12px; margin-top:8px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo').style.display = 'none';\n });\n\n tc.querySelector('#btn-save-company').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.email = tc.querySelector('#company-domain').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n \n store.saveSettings(settings);\n showToast('Company information saved permanently', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Email</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.email || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Default Tax Rate (GST)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" type=\"number\" value=\"10\" style=\"width:100px\" disabled /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\" data-tooltip=\"Create a new custom charge-out rate profile\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Base hourly charge billed to the client for this labor type\" data-tooltip-pos=\"right\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\" data-tooltip=\"Minimum flat fee billed if calculated hours compute below this value\" data-tooltip-pos=\"left\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', () => {\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n store.saveSettings(settings);\n showToast('Financial and Rate settings saved', 'success');\n renderContent();\n });\n\n } else if (activeTab === 'assets') {\n const settings = store.getSettings();\n const assets = store.getAll('assets').filter(a => a.category === 'Business');\n \n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Business Asset Defaults</h4>\n <div class=\"badge badge-info\">Recovery Automation Enabled</div>\n </div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px; margin-bottom:20px\">Configure the default recovery rates for your business equipment. These rates are used to calculate internal job costs when assets are assigned to tasks.</p>\n \n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Asset Name</th>\n <th>Current Rate ($/hr)</th>\n <th>Status</th>\n </tr>\n </thead>\n <tbody>\n ${assets.map(a => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(a.name)}</td>\n <td>\n <div style=\"display:flex; align-items:center; gap:8px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input asset-rate-input\" data-id=\"${a.id}\" value=\"${a.recoveryRate || 0}\" step=\"0.5\" style=\"width:100px; height:32px\" />\n </div>\n </td>\n <td><span class=\"badge badge-success\">Active</span></td>\n </tr>\n `).join('')}\n ${assets.length === 0 ? '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:24px\">No business assets found. Add assets in the main Assets module.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-asset-settings\">Save Asset Recovery Rates</button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-asset-settings').addEventListener('click', () => {\n tc.querySelectorAll('.asset-rate-input').forEach(inp => {\n const id = inp.dataset.id;\n const rate = parseFloat(inp.value) || 0;\n store.update('assets', id, { recoveryRate: rate });\n });\n showToast('Asset recovery rates updated across the system', 'success');\n });\n\n } else if (activeTab === 'system') {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:480px\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n ${currentUser.role === 'admin' ? `\n <hr style=\"margin:var(--space-md) 0; border:none; border-top:1px dashed var(--border-color);\" />\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups or seed a single test sample to explore the blank app layout.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed One Example Version\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n </div>\n ` : ''}\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a minimal example dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load <strong>exactly one example</strong> of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for quick feature walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed One Example Version\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Example\",\n className: \"btn-primary\",\n onClick: (close) => {\n close();\n seedMinimalData();\n showToast('Single-item example seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', email: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Email</label>\n <input class=\"form-input\" id=\"u-email\" value=\"${t.email || ''}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\">\n <option value=\"\">-- Select --</option>\n ${userTypes.map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('u-name').value;\n const email = document.getElementById('u-email').value;\n const role = document.getElementById('u-role').value;\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('technicians', editId, { name, email, role, userTypeId, color, payRate });\n } else {\n store.create('technicians', { name, email, role, userTypeId, color, payRate });\n }\n showToast('User saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n ${!isInfoPanelEditing ? `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:60%\" title=\"${escapeHTML(node.name)}\">${escapeHTML(node.name)}</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-primary btn-edit-info-tmpl\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">edit</span> Edit</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Name</div>\n <div style=\"font-size:14px; font-weight:500\">${escapeHTML(node.name)}</div>\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:14px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div style=\"display:flex; gap:16px; margin-bottom:12px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Estimated Hours</div>\n <div style=\"font-size:14px; font-weight:500\">${node.estimatedHours || 0} hrs</div>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">People</div>\n <div style=\"font-size:14px; font-weight:500\">${node.people || 1}</div>\n </div>\n </div>\n `}\n <div style=\"margin-top:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Description</div>\n <div style=\"font-size:13px; white-space:pre-wrap; color:var(--text-secondary)\">${escapeHTML(node.description || 'No description provided.')}</div>\n </div>\n ` : `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0\">Edit Item Details</h4>\n <div style=\"display:flex; gap:6px\">\n <button class=\"btn btn-xs btn-primary btn-done-info-tmpl\">Done</button>\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span></button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:0\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n `}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-lg',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = () => {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n store.saveSettings(updatedSettings);\n showToast('Material settings saved', 'success');\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n\n"}, "0b2ff3514ed5aefa755ac706c37f09a37589a1ad": {"t": 1785754218.8932645, "n": 110880, "h": "b59994afe9256611", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData } from '../data/seed.js';\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\n// Helper to render visual timeline\nfunction renderTimelineHtml(activeHours = []) {\n const hours = ['12am', '4am', '8am', '12pm', '4pm', '8pm', '12am'];\n \n // Format tooltip time range\n const formatTimeRange = (i) => {\n const pad = (num) => String(num).padStart(2, '0');\n const sh = Math.floor(i / 2);\n const sm = (i % 2) * 30;\n const eh = Math.floor((i + 1) / 2);\n const em = ((i + 1) % 2) * 30;\n return `${pad(sh)}:${pad(sm)} – ${pad(eh)}:${pad(em)}`;\n };\n\n let blocksHtml = '';\n for (let i = 0; i < 48; i++) {\n const isActive = activeHours.includes(i);\n blocksHtml += `\n <div class=\"timeline-block ${isActive ? 'active' : ''}\" \n data-slot=\"${i}\" \n title=\"${formatTimeRange(i)}\"\n ></div>\n `;\n }\n\n return `\n <div class=\"form-group timeline-container\" style=\"margin:0; grid-column:1/-1; user-select:none; position:relative;\">\n <label class=\"form-label\" style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;\">\n <span style=\"font-weight:600; display:flex; align-items:center; gap:6px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--color-primary)\">schedule</span>\n Active Hours Timeline\n </span>\n <span class=\"text-tertiary\" style=\"font-size:11px; font-weight:normal;\">Click & drag to highlight active hours</span>\n </label>\n \n <!-- Hour labels -->\n <div style=\"display:flex; justify-content:space-between; margin-bottom:4px; font-size:10px; color:var(--text-tertiary); font-weight:600; padding:0 2px;\">\n ${hours.map(h => `<span>${h}</span>`).join('')}\n </div>\n \n <!-- Grid -->\n <div class=\"timeline-grid\" style=\"\n display:grid; \n grid-template-columns:repeat(48, 1fr); \n gap:3px; \n background: rgba(0, 0, 0, 0.2); \n padding:6px; \n border-radius:8px; \n border:1px solid var(--border-color);\n touch-action:none;\n \">\n ${blocksHtml}\n </div>\n </div>\n `;\n}\n\n// Setup drag drawing / erasing\nfunction setupTimelineDragSelection(container) {\n let isDrawing = false;\n let drawMode = true; // true = draw, false = erase\n\n const onStart = (block) => {\n isDrawing = true;\n drawMode = !block.classList.contains('active');\n toggleBlock(block, drawMode);\n };\n\n const onMove = (block) => {\n if (isDrawing) {\n toggleBlock(block, drawMode);\n }\n };\n\n const toggleBlock = (block, active) => {\n if (active) {\n block.classList.add('active');\n } else {\n block.classList.remove('active');\n }\n };\n\n // Attach mouse listeners\n container.addEventListener('mousedown', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n e.preventDefault();\n onStart(block);\n }\n });\n\n // Use delegated mouseover which bubbles correctly\n container.addEventListener('mouseover', (e) => {\n const block = e.target.closest('.timeline-block');\n if (block) {\n onMove(block);\n }\n });\n\n const stopDrawing = () => {\n isDrawing = false;\n };\n\n window.addEventListener('mouseup', stopDrawing);\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'assets' ? 'active' : ''}\" data-tab=\"assets\">Assets</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'FieldForge Demo Company'}\" id=\"company-name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || '12 345 678 901'}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || '1300 123 456'}\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.email || 'fieldforge.io'}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\">${s.address || '123 Business St, Melbourne VIC 3000'}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; align-items:center; text-align:center\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo</label>\n <div id=\"logo-preview-container\" style=\"width:100%; aspect-ratio:1; margin:12px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:12px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:48px\">image</span>\n <span style=\"font-size:12px; margin-top:8px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; flex-direction:column; gap:8px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" style=\"width:100%\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">upload</span> Upload Logo\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" style=\"color:var(--color-danger); width:100%\">Remove Logo</button>` : ''}\n </div>\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:8px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n \n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogo = re.target.result;\n // Update ONLY the preview area and show hint\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Logo preview updated. Click Save to apply.', 'info');\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:48px\">image</span>\n <span style=\"font-size:12px; margin-top:8px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo').style.display = 'none';\n });\n\n tc.querySelector('#btn-save-company').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.email = tc.querySelector('#company-domain').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n \n store.saveSettings(settings);\n showToast('Company information saved permanently', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Email</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.email || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Default Tax Rate (GST)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" type=\"number\" value=\"10\" style=\"width:100px\" disabled /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"${rate.overtimeMultiplier || 1}\" />\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"${rate.description || ''}\" />\n ${renderTimelineHtml(rate.activeHours || [])}\n\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // Set up interactive timeline drag-selection\n setupTimelineDragSelection(tc);\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Overtime Multiplier (hidden) -->\n <input type=\"hidden\" class=\"rate-multiplier\" value=\"1.0\" />\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <!-- Description (hidden) & Active Hours Timeline -->\n <input type=\"hidden\" class=\"rate-desc\" value=\"\" />\n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n const activeHours = Array.from(card.querySelectorAll('.timeline-block.active')).map(block => parseInt(block.dataset.slot));\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, activeHours, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', () => {\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n store.saveSettings(settings);\n showToast('Financial and Rate settings saved', 'success');\n renderContent();\n });\n\n } else if (activeTab === 'assets') {\n const settings = store.getSettings();\n const assets = store.getAll('assets').filter(a => a.category === 'Business');\n \n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Business Asset Defaults</h4>\n <div class=\"badge badge-info\">Recovery Automation Enabled</div>\n </div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px; margin-bottom:20px\">Configure the default recovery rates for your business equipment. These rates are used to calculate internal job costs when assets are assigned to tasks.</p>\n \n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Asset Name</th>\n <th>Current Rate ($/hr)</th>\n <th>Status</th>\n </tr>\n </thead>\n <tbody>\n ${assets.map(a => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(a.name)}</td>\n <td>\n <div style=\"display:flex; align-items:center; gap:8px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input asset-rate-input\" data-id=\"${a.id}\" value=\"${a.recoveryRate || 0}\" step=\"0.5\" style=\"width:100px; height:32px\" />\n </div>\n </td>\n <td><span class=\"badge badge-success\">Active</span></td>\n </tr>\n `).join('')}\n ${assets.length === 0 ? '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:24px\">No business assets found. Add assets in the main Assets module.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-asset-settings\">Save Asset Recovery Rates</button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-asset-settings').addEventListener('click', () => {\n tc.querySelectorAll('.asset-rate-input').forEach(inp => {\n const id = inp.dataset.id;\n const rate = parseFloat(inp.value) || 0;\n store.update('assets', id, { recoveryRate: rate });\n });\n showToast('Asset recovery rates updated across the system', 'success');\n });\n\n } else if (activeTab === 'system') {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:480px\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n ${currentUser.role === 'admin' ? `\n <hr style=\"margin:var(--space-md) 0; border:none; border-top:1px dashed var(--border-color);\" />\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups or seed a single test sample to explore the blank app layout.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed One Example Version\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n </div>\n ` : ''}\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a minimal example dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load <strong>exactly one example</strong> of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for quick feature walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed One Example Version\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Example\",\n className: \"btn-primary\",\n onClick: (close) => {\n close();\n seedMinimalData();\n showToast('Single-item example seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', email: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Email</label>\n <input class=\"form-input\" id=\"u-email\" value=\"${t.email || ''}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\">\n <option value=\"\">-- Select --</option>\n ${userTypes.map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('u-name').value;\n const email = document.getElementById('u-email').value;\n const role = document.getElementById('u-role').value;\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('technicians', editId, { name, email, role, userTypeId, color, payRate });\n } else {\n store.create('technicians', { name, email, role, userTypeId, color, payRate });\n }\n showToast('User saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n ${!isInfoPanelEditing ? `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:60%\" title=\"${escapeHTML(node.name)}\">${escapeHTML(node.name)}</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-primary btn-edit-info-tmpl\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">edit</span> Edit</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Name</div>\n <div style=\"font-size:14px; font-weight:500\">${escapeHTML(node.name)}</div>\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:14px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div style=\"display:flex; gap:16px; margin-bottom:12px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Estimated Hours</div>\n <div style=\"font-size:14px; font-weight:500\">${node.estimatedHours || 0} hrs</div>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">People</div>\n <div style=\"font-size:14px; font-weight:500\">${node.people || 1}</div>\n </div>\n </div>\n `}\n <div style=\"margin-top:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Description</div>\n <div style=\"font-size:13px; white-space:pre-wrap; color:var(--text-secondary)\">${escapeHTML(node.description || 'No description provided.')}</div>\n </div>\n ` : `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0\">Edit Item Details</h4>\n <div style=\"display:flex; gap:6px\">\n <button class=\"btn btn-xs btn-primary btn-done-info-tmpl\">Done</button>\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span></button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:0\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n `}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-lg',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = () => {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n store.saveSettings(updatedSettings);\n showToast('Material settings saved', 'success');\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n\n"}, "f98df4fd2f940d31d3f5d4cf4abbd21cb9d0267d": {"t": 1785754220.808574, "n": 108118, "h": "9e384077a469fd69", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\nimport { seedMinimalData } from '../data/seed.js';\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'assets' ? 'active' : ''}\" data-tab=\"assets\">Assets</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'FieldForge Demo Company'}\" id=\"company-name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || '12 345 678 901'}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || '1300 123 456'}\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.email || 'fieldforge.io'}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\">${s.address || '123 Business St, Melbourne VIC 3000'}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; align-items:center; text-align:center\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo</label>\n <div id=\"logo-preview-container\" style=\"width:100%; aspect-ratio:1; margin:12px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:12px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:48px\">image</span>\n <span style=\"font-size:12px; margin-top:8px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; flex-direction:column; gap:8px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" style=\"width:100%\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">upload</span> Upload Logo\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" style=\"color:var(--color-danger); width:100%\">Remove Logo</button>` : ''}\n </div>\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:8px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n \n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogo = re.target.result;\n // Update ONLY the preview area and show hint\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Logo preview updated. Click Save to apply.', 'info');\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:48px\">image</span>\n <span style=\"font-size:12px; margin-top:8px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo').style.display = 'none';\n });\n\n tc.querySelector('#btn-save-company').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.email = tc.querySelector('#company-domain').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n \n store.saveSettings(settings);\n showToast('Company information saved permanently', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Email</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.email || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Default Tax Rate (GST)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" type=\"number\" value=\"10\" style=\"width:100px\" disabled /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:flex;flex-direction:column;gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Overtime Multiplier</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <input class=\"form-input rate-multiplier\" type=\"number\" value=\"${(rate.overtimeMultiplier || 1).toFixed(1)}\" min=\"1\" max=\"5\" step=\"0.5\" style=\"width:100px\" />\n <span class=\"text-secondary\">× base pay</span>\n </div>\n </div>\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input rate-desc\" value=\"${rate.description || ''}\" placeholder=\"When is this rate used?\" />\n </div>\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Overtime Multiplier</label>\n <input class=\"form-input rate-multiplier\" type=\"number\" value=\"1.0\" min=\"1\" max=\"5\" step=\"0.5\" style=\"width:100px\" />\n </div>\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input rate-desc\" value=\"\" />\n </div>\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', () => {\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n store.saveSettings(settings);\n showToast('Financial and Rate settings saved', 'success');\n renderContent();\n });\n\n } else if (activeTab === 'assets') {\n const settings = store.getSettings();\n const assets = store.getAll('assets').filter(a => a.category === 'Business');\n \n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Business Asset Defaults</h4>\n <div class=\"badge badge-info\">Recovery Automation Enabled</div>\n </div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px; margin-bottom:20px\">Configure the default recovery rates for your business equipment. These rates are used to calculate internal job costs when assets are assigned to tasks.</p>\n \n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Asset Name</th>\n <th>Current Rate ($/hr)</th>\n <th>Status</th>\n </tr>\n </thead>\n <tbody>\n ${assets.map(a => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(a.name)}</td>\n <td>\n <div style=\"display:flex; align-items:center; gap:8px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input asset-rate-input\" data-id=\"${a.id}\" value=\"${a.recoveryRate || 0}\" step=\"0.5\" style=\"width:100px; height:32px\" />\n </div>\n </td>\n <td><span class=\"badge badge-success\">Active</span></td>\n </tr>\n `).join('')}\n ${assets.length === 0 ? '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:24px\">No business assets found. Add assets in the main Assets module.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-asset-settings\">Save Asset Recovery Rates</button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-asset-settings').addEventListener('click', () => {\n tc.querySelectorAll('.asset-rate-input').forEach(inp => {\n const id = inp.dataset.id;\n const rate = parseFloat(inp.value) || 0;\n store.update('assets', id, { recoveryRate: rate });\n });\n showToast('Asset recovery rates updated across the system', 'success');\n });\n\n } else if (activeTab === 'system') {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:480px\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n ${currentUser.role === 'admin' ? `\n <hr style=\"margin:var(--space-md) 0; border:none; border-top:1px dashed var(--border-color);\" />\n <div style=\"background:var(--color-danger-bg); padding:var(--space-md); border-radius:var(--border-radius); border:1px solid rgba(220, 38, 38, 0.15)\">\n <h5 style=\"color:var(--color-danger); margin-bottom:8px; display:flex; align-items:center; gap:6px; font-weight:600;\">\n <span class=\"material-icons-outlined\">admin_panel_settings</span> Administrator Actions\n </h5>\n <p style=\"font-size:var(--font-size-sm); color:var(--text-secondary); margin-bottom:var(--space-md); line-height:1.4;\">\n Configure clean setups or seed a single test sample to explore the blank app layout.\n </p>\n <button class=\"btn btn-secondary\" id=\"btn-seed-minimal\" style=\"width:100%; justify-content:center; margin-bottom:12px; border:1px solid rgba(0,0,0,0.12)\">\n <span class=\"material-icons-outlined\">science</span> Seed One Example Version\n </button>\n <button class=\"btn btn-danger\" id=\"btn-restore-new\" style=\"width:100%; justify-content:center;\">\n <span class=\"material-icons-outlined\">cleaning_services</span> Restore to New (Blank State)\n </button>\n </div>\n ` : ''}\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-seed-minimal')?.addEventListener('click', () => {\n const content = document.createElement('div');\n content.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to seed a minimal example dataset.</p>\n <div style=\"background:var(--color-info-bg); border-left:4px solid var(--color-info); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-info); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">info</span>\n <span>This will clear current database records and load <strong>exactly one example</strong> of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">This is highly recommended for quick feature walkthroughs.</p>\n `;\n\n showModal({\n title: \"Seed One Example Version\",\n content: content,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close) => close()\n },\n {\n label: \"Seed Example\",\n className: \"btn-primary\",\n onClick: (close) => {\n close();\n seedMinimalData();\n showToast('Single-item example seeded. Reloading...', 'success');\n setTimeout(() => window.location.reload(), 1200);\n }\n }\n ]\n });\n });\n\n tc.querySelector('#btn-restore-new')?.addEventListener('click', () => {\n const content1 = document.createElement('div');\n content1.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content1.innerHTML = `\n <p style=\"margin-bottom:12px\">You are about to restore the application to a blank state.</p>\n <div style=\"background:var(--color-warning-bg); border-left:4px solid var(--color-warning); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-warning); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">warning</span>\n <span><strong>What gets wiped:</strong> Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Only default User Types and Technician login credentials will be retained so you can log back in.</p>\n `;\n\n showModal({\n title: \"Restore to New (Blank State)\",\n content: content1,\n actions: [\n {\n label: \"Cancel\",\n className: \"btn-secondary\",\n onClick: (close1) => close1()\n },\n {\n label: \"Continue Wiping\",\n className: \"btn-danger\",\n onClick: (close1) => {\n close1();\n \n const content2 = document.createElement('div');\n content2.style.cssText = 'line-height:1.6; color:var(--text-primary);';\n content2.innerHTML = `\n <p style=\"margin-bottom:12px; font-weight:600; color:var(--color-danger)\">THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!</p>\n <div style=\"background:var(--color-danger-bg); border-left:4px solid var(--color-danger); padding:12px; margin-bottom:16px; border-radius:4px; font-size:12.5px; color:var(--color-danger); font-weight:500; display:flex; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\">error_outline</span>\n <span>Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.</span>\n </div>\n <p style=\"font-size:12px; color:var(--text-secondary)\">Are you absolutely 100% sure you want to proceed?</p>\n `;\n\n showModal({\n title: \"⚠️ Permanent Database Wipe\",\n content: content2,\n actions: [\n {\n label: \"Abort Wiping\",\n className: \"btn-secondary\",\n onClick: (close2) => close2()\n },\n {\n label: \"Yes, Wipe Everything!\",\n className: \"btn-danger\",\n onClick: (close2) => {\n close2();\n \n store.clearAll();\n localStorage.setItem('simpro__prevent_seeding', 'true');\n localStorage.setItem('simpro__seeded', 'true');\n localStorage.removeItem('currentUser');\n \n showToast('App restored to fresh state. Reloading...', 'success');\n \n setTimeout(() => {\n window.location.hash = '#/login';\n window.location.reload();\n }, 1200);\n }\n }\n ]\n });\n }\n }\n ]\n });\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', email: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Email</label>\n <input class=\"form-input\" id=\"u-email\" value=\"${t.email || ''}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\">\n <option value=\"\">-- Select --</option>\n ${userTypes.map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('u-name').value;\n const email = document.getElementById('u-email').value;\n const role = document.getElementById('u-role').value;\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('technicians', editId, { name, email, role, userTypeId, color, payRate });\n } else {\n store.create('technicians', { name, email, role, userTypeId, color, payRate });\n }\n showToast('User saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n ${!isInfoPanelEditing ? `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:60%\" title=\"${escapeHTML(node.name)}\">${escapeHTML(node.name)}</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-primary btn-edit-info-tmpl\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">edit</span> Edit</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Name</div>\n <div style=\"font-size:14px; font-weight:500\">${escapeHTML(node.name)}</div>\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:14px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div style=\"display:flex; gap:16px; margin-bottom:12px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Estimated Hours</div>\n <div style=\"font-size:14px; font-weight:500\">${node.estimatedHours || 0} hrs</div>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">People</div>\n <div style=\"font-size:14px; font-weight:500\">${node.people || 1}</div>\n </div>\n </div>\n `}\n <div style=\"margin-top:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Description</div>\n <div style=\"font-size:13px; white-space:pre-wrap; color:var(--text-secondary)\">${escapeHTML(node.description || 'No description provided.')}</div>\n </div>\n ` : `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0\">Edit Item Details</h4>\n <div style=\"display:flex; gap:6px\">\n <button class=\"btn btn-xs btn-primary btn-done-info-tmpl\">Done</button>\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span></button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:0\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n `}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-lg',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = () => {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n store.saveSettings(updatedSettings);\n showToast('Material settings saved', 'success');\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n\n"}, "32c0277a639709c736c6818e4dd47378d70795c3": {"t": 1785754222.4810677, "n": 101132, "h": "b06c07c1ddaadde3", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'assets' ? 'active' : ''}\" data-tab=\"assets\">Assets</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'FieldForge Demo Company'}\" id=\"company-name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || '12 345 678 901'}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || '1300 123 456'}\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.email || 'fieldforge.io'}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\">${s.address || '123 Business St, Melbourne VIC 3000'}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; align-items:center; text-align:center\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo</label>\n <div id=\"logo-preview-container\" style=\"width:100%; aspect-ratio:1; margin:12px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:12px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:48px\">image</span>\n <span style=\"font-size:12px; margin-top:8px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; flex-direction:column; gap:8px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" style=\"width:100%\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">upload</span> Upload Logo\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" style=\"color:var(--color-danger); width:100%\">Remove Logo</button>` : ''}\n </div>\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:8px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n \n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogo = re.target.result;\n // Update ONLY the preview area and show hint\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Logo preview updated. Click Save to apply.', 'info');\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:48px\">image</span>\n <span style=\"font-size:12px; margin-top:8px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo').style.display = 'none';\n });\n\n tc.querySelector('#btn-save-company').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.email = tc.querySelector('#company-domain').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n \n store.saveSettings(settings);\n showToast('Company information saved permanently', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Email</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.email || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Default Tax Rate (GST)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" type=\"number\" value=\"10\" style=\"width:100px\" disabled /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:flex;flex-direction:column;gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Overtime Multiplier</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <input class=\"form-input rate-multiplier\" type=\"number\" value=\"${(rate.overtimeMultiplier || 1).toFixed(1)}\" min=\"1\" max=\"5\" step=\"0.5\" style=\"width:100px\" />\n <span class=\"text-secondary\">× base pay</span>\n </div>\n </div>\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input rate-desc\" value=\"${rate.description || ''}\" placeholder=\"When is this rate used?\" />\n </div>\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Overtime Multiplier</label>\n <input class=\"form-input rate-multiplier\" type=\"number\" value=\"1.0\" min=\"1\" max=\"5\" step=\"0.5\" style=\"width:100px\" />\n </div>\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input rate-desc\" value=\"\" />\n </div>\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', () => {\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n store.saveSettings(settings);\n showToast('Financial and Rate settings saved', 'success');\n renderContent();\n });\n\n } else if (activeTab === 'assets') {\n const settings = store.getSettings();\n const assets = store.getAll('assets').filter(a => a.category === 'Business');\n \n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Business Asset Defaults</h4>\n <div class=\"badge badge-info\">Recovery Automation Enabled</div>\n </div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px; margin-bottom:20px\">Configure the default recovery rates for your business equipment. These rates are used to calculate internal job costs when assets are assigned to tasks.</p>\n \n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Asset Name</th>\n <th>Current Rate ($/hr)</th>\n <th>Status</th>\n </tr>\n </thead>\n <tbody>\n ${assets.map(a => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(a.name)}</td>\n <td>\n <div style=\"display:flex; align-items:center; gap:8px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input asset-rate-input\" data-id=\"${a.id}\" value=\"${a.recoveryRate || 0}\" step=\"0.5\" style=\"width:100px; height:32px\" />\n </div>\n </td>\n <td><span class=\"badge badge-success\">Active</span></td>\n </tr>\n `).join('')}\n ${assets.length === 0 ? '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:24px\">No business assets found. Add assets in the main Assets module.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-asset-settings\">Save Asset Recovery Rates</button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-asset-settings').addEventListener('click', () => {\n tc.querySelectorAll('.asset-rate-input').forEach(inp => {\n const id = inp.dataset.id;\n const rate = parseFloat(inp.value) || 0;\n store.update('assets', id, { recoveryRate: rate });\n });\n showToast('Asset recovery rates updated across the system', 'success');\n });\n\n } else if (activeTab === 'system') {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:480px\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', email: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Email</label>\n <input class=\"form-input\" id=\"u-email\" value=\"${t.email || ''}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\">\n <option value=\"\">-- Select --</option>\n ${userTypes.map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('u-name').value;\n const email = document.getElementById('u-email').value;\n const role = document.getElementById('u-role').value;\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('technicians', editId, { name, email, role, userTypeId, color, payRate });\n } else {\n store.create('technicians', { name, email, role, userTypeId, color, payRate });\n }\n showToast('User saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n ${!isInfoPanelEditing ? `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:60%\" title=\"${escapeHTML(node.name)}\">${escapeHTML(node.name)}</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-primary btn-edit-info-tmpl\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">edit</span> Edit</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Name</div>\n <div style=\"font-size:14px; font-weight:500\">${escapeHTML(node.name)}</div>\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:14px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div style=\"display:flex; gap:16px; margin-bottom:12px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Estimated Hours</div>\n <div style=\"font-size:14px; font-weight:500\">${node.estimatedHours || 0} hrs</div>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">People</div>\n <div style=\"font-size:14px; font-weight:500\">${node.people || 1}</div>\n </div>\n </div>\n `}\n <div style=\"margin-top:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Description</div>\n <div style=\"font-size:13px; white-space:pre-wrap; color:var(--text-secondary)\">${escapeHTML(node.description || 'No description provided.')}</div>\n </div>\n ` : `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0\">Edit Item Details</h4>\n <div style=\"display:flex; gap:6px\">\n <button class=\"btn btn-xs btn-primary btn-done-info-tmpl\">Done</button>\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span></button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:0\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n `}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-lg',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n router.navigate('/settings/quote-templates/new');\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n router.navigate(`/settings/quote-templates/${btn.dataset.id}/edit`);\n });\n });\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = () => {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n store.saveSettings(updatedSettings);\n showToast('Material settings saved', 'success');\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n\n"}, "c9ab21ba7bc98b97c8d617f35583e54ee8e22496": {"t": 1785754224.4118304, "n": 102680, "h": "8f77d2f800f2d228", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\nimport { router } from '../router.js';\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nexport function renderSettings(container) {\n const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || window.location.search);\n const tabParam = urlParams.get('tab');\n \n let activeTab = 'company';\n let templatesSubTab = 'tasklists';\n\n if (tabParam === 'forms') {\n activeTab = 'templates_forms';\n templatesSubTab = 'forms';\n } else if (tabParam === 'tasks' || tabParam === 'tasklists') {\n activeTab = 'templates_forms';\n templatesSubTab = 'tasklists';\n } else if (tabParam === 'quote_templates' || tabParam === 'quotes') {\n activeTab = 'templates_forms';\n templatesSubTab = 'quotes';\n } else if (tabParam) {\n activeTab = tabParam;\n }\n\n const currentUser = JSON.parse(localStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'templates_forms' ? 'active' : ''}\" data-tab=\"templates_forms\">Templates &amp; Forms</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'assets' ? 'active' : ''}\" data-tab=\"assets\">Assets</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'templates_forms') {\n renderTemplatesFormsTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'FieldForge Demo Company'}\" id=\"company-name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || '12 345 678 901'}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || '1300 123 456'}\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.email || 'fieldforge.io'}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\">${s.address || '123 Business St, Melbourne VIC 3000'}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; align-items:center; text-align:center\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo</label>\n <div id=\"logo-preview-container\" style=\"width:100%; aspect-ratio:1; margin:12px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:12px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:48px\">image</span>\n <span style=\"font-size:12px; margin-top:8px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; flex-direction:column; gap:8px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" style=\"width:100%\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">upload</span> Upload Logo\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" style=\"color:var(--color-danger); width:100%\">Remove Logo</button>` : ''}\n </div>\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:8px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n \n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogo = re.target.result;\n // Update ONLY the preview area and show hint\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Logo preview updated. Click Save to apply.', 'info');\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:48px\">image</span>\n <span style=\"font-size:12px; margin-top:8px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo').style.display = 'none';\n });\n\n tc.querySelector('#btn-save-company').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.email = tc.querySelector('#company-domain').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n \n store.saveSettings(settings);\n showToast('Company information saved permanently', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Email</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.email || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Default Tax Rate (GST)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" type=\"number\" value=\"10\" style=\"width:100px\" disabled /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:flex;flex-direction:column;gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Overtime Multiplier</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <input class=\"form-input rate-multiplier\" type=\"number\" value=\"${(rate.overtimeMultiplier || 1).toFixed(1)}\" min=\"1\" max=\"5\" step=\"0.5\" style=\"width:100px\" />\n <span class=\"text-secondary\">× base pay</span>\n </div>\n </div>\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input rate-desc\" value=\"${rate.description || ''}\" placeholder=\"When is this rate used?\" />\n </div>\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Overtime Multiplier</label>\n <input class=\"form-input rate-multiplier\" type=\"number\" value=\"1.0\" min=\"1\" max=\"5\" step=\"0.5\" style=\"width:100px\" />\n </div>\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input rate-desc\" value=\"\" />\n </div>\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', () => {\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n store.saveSettings(settings);\n showToast('Financial and Rate settings saved', 'success');\n renderContent();\n });\n\n } else if (activeTab === 'assets') {\n const settings = store.getSettings();\n const assets = store.getAll('assets').filter(a => a.category === 'Business');\n \n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Business Asset Defaults</h4>\n <div class=\"badge badge-info\">Recovery Automation Enabled</div>\n </div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px; margin-bottom:20px\">Configure the default recovery rates for your business equipment. These rates are used to calculate internal job costs when assets are assigned to tasks.</p>\n \n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Asset Name</th>\n <th>Current Rate ($/hr)</th>\n <th>Status</th>\n </tr>\n </thead>\n <tbody>\n ${assets.map(a => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(a.name)}</td>\n <td>\n <div style=\"display:flex; align-items:center; gap:8px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input asset-rate-input\" data-id=\"${a.id}\" value=\"${a.recoveryRate || 0}\" step=\"0.5\" style=\"width:100px; height:32px\" />\n </div>\n </td>\n <td><span class=\"badge badge-success\">Active</span></td>\n </tr>\n `).join('')}\n ${assets.length === 0 ? '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:24px\">No business assets found. Add assets in the main Assets module.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-asset-settings\">Save Asset Recovery Rates</button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-asset-settings').addEventListener('click', () => {\n tc.querySelectorAll('.asset-rate-input').forEach(inp => {\n const id = inp.dataset.id;\n const rate = parseFloat(inp.value) || 0;\n store.update('assets', id, { recoveryRate: rate });\n });\n showToast('Asset recovery rates updated across the system', 'success');\n });\n\n } else if (activeTab === 'system') {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:480px\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', email: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Email</label>\n <input class=\"form-input\" id=\"u-email\" value=\"${t.email || ''}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\">\n <option value=\"\">-- Select --</option>\n ${userTypes.map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('u-name').value;\n const email = document.getElementById('u-email').value;\n const role = document.getElementById('u-role').value;\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('technicians', editId, { name, email, role, userTypeId, color, payRate });\n } else {\n store.create('technicians', { name, email, role, userTypeId, color, payRate });\n }\n showToast('User saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n ${!isInfoPanelEditing ? `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:60%\" title=\"${escapeHTML(node.name)}\">${escapeHTML(node.name)}</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-primary btn-edit-info-tmpl\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">edit</span> Edit</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Name</div>\n <div style=\"font-size:14px; font-weight:500\">${escapeHTML(node.name)}</div>\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:14px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div style=\"display:flex; gap:16px; margin-bottom:12px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Estimated Hours</div>\n <div style=\"font-size:14px; font-weight:500\">${node.estimatedHours || 0} hrs</div>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">People</div>\n <div style=\"font-size:14px; font-weight:500\">${node.people || 1}</div>\n </div>\n </div>\n `}\n <div style=\"margin-top:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Description</div>\n <div style=\"font-size:13px; white-space:pre-wrap; color:var(--text-secondary)\">${escapeHTML(node.description || 'No description provided.')}</div>\n </div>\n ` : `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0\">Edit Item Details</h4>\n <div style=\"display:flex; gap:6px\">\n <button class=\"btn btn-xs btn-primary btn-done-info-tmpl\">Done</button>\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span></button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:0\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n `}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-lg',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n openEditQuoteTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditQuoteTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditQuoteTemplateModal(editId = null) {\n const t = editId ? store.getById('quoteTemplates', editId) : { name: '', description: '', sections: [] };\n const content = document.createElement('div');\n content.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-qtmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <textarea class=\"form-input\" id=\"edit-qtmpl-desc\" rows=\"3\">${escapeHTML(t.description || '')}</textarea>\n </div>\n `;\n\n showModal({\n title: editId ? 'Edit Quote Template' : 'Create Quote Template',\n content,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-qtmpl-name').value;\n const description = content.querySelector('#edit-qtmpl-desc').value;\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n if (editId) {\n store.update('quoteTemplates', editId, { name, description });\n } else {\n store.create('quoteTemplates', { name, description, sections: [] });\n }\n showToast('Quote template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = () => {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n store.saveSettings(updatedSettings);\n showToast('Material settings saved', 'success');\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n function renderTemplatesFormsTab(tc) {\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-md)\">\n <div class=\"card-body\" style=\"padding: 8px; background:var(--bg-color); border-radius: 8px; display:flex; gap:8px\">\n <button class=\"btn btn-sm\" id=\"subtab-tasklists\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'tasklists' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'tasklists' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">playlist_add_check</span> Tasklist Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-forms\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'forms' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'forms' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">assignment</span> Form Templates\n </button>\n <button class=\"btn btn-sm\" id=\"subtab-quotes\" style=\"flex:1; display:flex; align-items:center; justify-content:center; gap:8px; border:none; border-radius:6px; padding:10px; background:${templatesSubTab === 'quotes' ? 'var(--color-primary)' : 'transparent'}; color:${templatesSubTab === 'quotes' ? 'white' : 'var(--text-color)'}; font-weight:600; cursor:pointer; transition:all 0.2s ease;\">\n <span class=\"material-icons-outlined\" style=\"font-size:18px\">article</span> Quote Templates\n </button>\n </div>\n </div>\n <div id=\"templates-subcontent\" style=\"margin-top:var(--space-md)\"></div>\n `;\n\n const btnTasklists = tc.querySelector('#subtab-tasklists');\n const btnForms = tc.querySelector('#subtab-forms');\n const btnQuotes = tc.querySelector('#subtab-quotes');\n\n // Style elements for visual elegance\n if (templatesSubTab === 'tasklists') btnTasklists.style.color = 'white';\n if (templatesSubTab === 'forms') btnForms.style.color = 'white';\n if (templatesSubTab === 'quotes') btnQuotes.style.color = 'white';\n\n const subcontent = tc.querySelector('#templates-subcontent');\n\n if (templatesSubTab === 'tasklists') {\n renderTasksSettings(subcontent);\n } else if (templatesSubTab === 'forms') {\n renderFormsTab(subcontent);\n } else if (templatesSubTab === 'quotes') {\n renderQuoteTemplatesSettings(subcontent);\n }\n\n btnTasklists.addEventListener('click', () => {\n templatesSubTab = 'tasklists';\n renderTemplatesFormsTab(tc);\n });\n btnForms.addEventListener('click', () => {\n templatesSubTab = 'forms';\n renderTemplatesFormsTab(tc);\n });\n btnQuotes.addEventListener('click', () => {\n templatesSubTab = 'quotes';\n renderTemplatesFormsTab(tc);\n });\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n\n\n"}, "7c7c2762698c02bccfc68cc0a71385ab99ea15e2": {"t": 1785754230.3304875, "n": 99264, "h": "4390a2f6a8a193b7", "c": "// ============================================\n// SIMPRO CLONE — SETTINGS PAGE\n// ============================================\n\nimport { store } from '../data/store.js';\nimport { showToast } from '../components/Notifications.js';\nimport { showModal } from '../components/Modal.js';\nimport { MODULE_PERMS } from '../utils/permissions.js';\nimport { escapeHTML } from '../utils/security.js';\n\n// Build a permissions array with all granular keys\nfunction buildGranularPerms(valueFn) {\n return Object.entries(MODULE_PERMS).map(([module, perms]) => {\n const obj = { module };\n perms.forEach(({ key }) => { obj[key] = valueFn(module, key); });\n return obj;\n });\n}\n\nexport function renderSettings(container) {\n let activeTab = 'company';\n const currentUser = JSON.parse(sessionStorage.getItem('currentUser') || '{\"role\":\"admin\"}');\n\n function render() {\n container.innerHTML = `\n <div class=\"page-header\"><h1>Settings</h1></div>\n\n <div class=\"tabs\" style=\"margin-bottom:0\">\n <button class=\"tab ${activeTab === 'company' ? 'active' : ''}\" data-tab=\"company\">Company</button>\n <button class=\"tab ${activeTab === 'users' ? 'active' : ''}\" data-tab=\"users\">Users & Permissions</button>\n <button class=\"tab ${activeTab === 'materials' ? 'active' : ''}\" data-tab=\"materials\">Materials</button>\n <button class=\"tab ${activeTab === 'tasks' ? 'active' : ''}\" data-tab=\"tasks\">Tasklists</button>\n <button class=\"tab ${activeTab === 'forms' ? 'active' : ''}\" data-tab=\"forms\">Custom Forms</button>\n <button class=\"tab ${activeTab === 'quote_templates' ? 'active' : ''}\" data-tab=\"quote_templates\">Quote Templates</button>\n <button class=\"tab ${activeTab === 'tax' ? 'active' : ''}\" data-tab=\"tax\">Tax &amp; Rates</button>\n <button class=\"tab ${activeTab === 'assets' ? 'active' : ''}\" data-tab=\"assets\">Assets</button>\n <button class=\"tab ${activeTab === 'system' ? 'active' : ''}\" data-tab=\"system\">System</button>\n </div>\n <div id=\"settings-content\" style=\"padding-top:var(--space-lg)\"></div>\n `;\n\n renderContent();\n\n container.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n activeTab = tab.dataset.tab;\n container.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n renderContent();\n });\n });\n }\n\n function renderContent() {\n const tc = container.querySelector('#settings-content');\n\n if (activeTab === 'forms') {\n renderFormsTab(tc);\n return;\n }\n\n if (activeTab === 'company') {\n const s = store.getSettings();\n // Use local variables to track changes before saving\n let pendingLogo = s.logo;\n \n const renderCompanyTab = () => {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\"><h4>Company Information</h4></div>\n <div class=\"card-body\">\n <div style=\"display:grid; grid-template-columns: 1fr 280px; gap:var(--space-lg)\">\n <div style=\"display:flex; flex-direction:column; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Company Name</label>\n <input class=\"form-input\" value=\"${s.name || 'FieldForge Demo Company'}\" id=\"company-name\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">ABN</label>\n <input class=\"form-input\" id=\"company-abn\" value=\"${s.abn || '12 345 678 901'}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Phone</label>\n <input class=\"form-input\" id=\"company-phone\" value=\"${s.phone || '1300 123 456'}\" />\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Company Domain</label>\n <input class=\"form-input\" value=\"${s.email || 'fieldforge.io'}\" id=\"company-domain\" placeholder=\"e.g. yourcompany.com.au\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Address</label>\n <textarea class=\"form-textarea\" id=\"company-address\" rows=\"2\">${s.address || '123 Business St, Melbourne VIC 3000'}</textarea>\n </div>\n </div>\n\n <!-- Logo Section -->\n <div style=\"border-left:1px solid var(--border-color); padding-left:var(--space-lg); display:flex; flex-direction:column; align-items:center; text-align:center\">\n <label class=\"form-label\" style=\"align-self:flex-start\">Company Logo</label>\n <div id=\"logo-preview-container\" style=\"width:100%; aspect-ratio:1; margin:12px 0; background:var(--bg-color); border:1px dashed var(--border-color); border-radius:12px; display:flex; align-items:center; justify-content:center; overflow:hidden\">\n ${pendingLogo ? `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />` : `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:48px\">image</span>\n <span style=\"font-size:12px; margin-top:8px\">No custom logo</span>\n </div>\n `}\n </div>\n <input type=\"file\" id=\"logo-upload\" accept=\"image/*\" style=\"display:none\" />\n <div style=\"display:flex; flex-direction:column; gap:8px; width:100%\">\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-upload-logo\" style=\"width:100%\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">upload</span> Upload Logo\n </button>\n ${pendingLogo ? `<button class=\"btn btn-ghost btn-sm\" id=\"btn-remove-logo\" style=\"color:var(--color-danger); width:100%\">Remove Logo</button>` : ''}\n </div>\n <div id=\"unsaved-logo-hint\" style=\"display:none; margin-top:8px; color:var(--color-warning); font-size:11px; font-weight:600\">UNSAVED PREVIEW</div>\n </div>\n </div>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-company\">\n <span class=\"material-icons-outlined\">save</span> Save Company Changes\n </button>\n </div>\n </div>\n `;\n\n // Handlers for Company Tab\n const logoInput = tc.querySelector('#logo-upload');\n tc.querySelector('#btn-upload-logo').addEventListener('click', () => logoInput.click());\n \n logoInput.addEventListener('change', (e) => {\n const file = e.target.files[0];\n if (file) {\n const reader = new FileReader();\n reader.onload = (re) => {\n pendingLogo = re.target.result;\n // Update ONLY the preview area and show hint\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `<img src=\"${pendingLogo}\" style=\"max-width:90%; max-height:90%; object-fit:contain\" />`;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n showToast('Logo preview updated. Click Save to apply.', 'info');\n };\n reader.readAsDataURL(file);\n }\n });\n\n tc.querySelector('#btn-remove-logo')?.addEventListener('click', () => {\n pendingLogo = null;\n const container = tc.querySelector('#logo-preview-container');\n container.innerHTML = `\n <div style=\"display:flex; flex-direction:column; align-items:center; color:var(--text-tertiary)\">\n <span class=\"material-icons-outlined\" style=\"font-size:48px\">image</span>\n <span style=\"font-size:12px; margin-top:8px\">No custom logo</span>\n </div>\n `;\n tc.querySelector('#unsaved-logo-hint').style.display = 'block';\n tc.querySelector('#btn-remove-logo').style.display = 'none';\n });\n\n tc.querySelector('#btn-save-company').addEventListener('click', () => {\n const settings = store.getSettings();\n settings.name = tc.querySelector('#company-name').value;\n settings.abn = tc.querySelector('#company-abn').value;\n settings.phone = tc.querySelector('#company-phone').value;\n settings.email = tc.querySelector('#company-domain').value;\n settings.address = tc.querySelector('#company-address').value;\n settings.logo = pendingLogo;\n \n store.saveSettings(settings);\n showToast('Company information saved permanently', 'success');\n tc.querySelector('#unsaved-logo-hint').style.display = 'none';\n window.dispatchEvent(new CustomEvent('simpro-settings-updated'));\n });\n };\n\n renderCompanyTab();\n } else if (activeTab === 'users') {\n const techs = store.getAll('technicians');\n let userTypes = store.getAll('userTypes');\n if (!userTypes || userTypes.length === 0) {\n userTypes = [\n { id: 'ut_admin', name: 'Admin', description: 'Full system access',\n permissions: buildGranularPerms(() => true) },\n { id: 'ut_manager', name: 'Manager', description: 'Can manage most workflows but limited settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n })\n },\n { id: 'ut_tech', name: 'Technician', description: 'Field staff — limited to their own jobs',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n })\n },\n { id: 'ut_office', name: 'Office Staff', description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n }\n ];\n store.save('userTypes', userTypes);\n } else {\n // Self-healing migration: Restore Office Staff role if it was wiped\n const hasOffice = userTypes.some(ut => ut.id === 'ut_office');\n if (!hasOffice) {\n userTypes.push({\n id: 'ut_office',\n name: 'Office Staff',\n description: 'Admin / reception — can manage customers, quotes, invoices but not system settings',\n permissions: buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n })\n });\n store.save('userTypes', userTypes);\n }\n }\n\n tc.innerHTML = `\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Active Users</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-user\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add User</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th style=\"width:40px\"></th>\n <th>Name</th>\n <th>Role</th>\n <th>User Type</th>\n <th>Email</th>\n <th>Pay Rate</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n <tr>\n <td><div style=\"width:12px; height:12px; border-radius:50%; background:${t.color}\"></div></td>\n <td class=\"font-medium\">${t.name}</td>\n <td class=\"text-secondary\">${t.role}</td>\n <td><span class=\"badge ${ut?.id === 'ut_admin' ? 'badge-primary' : 'badge-neutral'}\">${ut?.name || 'Unassigned'}</span></td>\n <td class=\"text-tertiary\">${t.email || '-'}</td>\n <td class=\"text-secondary\">${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-icon btn-sm btn-edit-user\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-icon btn-sm text-danger btn-deactivate-user\" data-id=\"${t.id}\" title=\"Deactivate\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">person_off</span></button>\n </div>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-bottom:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">User Types & Permissions</h4>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-usertype\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Type</button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead><tr><th>Name</th><th>Description</th><th>Actions</th></tr></thead>\n <tbody>\n ${userTypes.map(ut => `\n <tr>\n <td class=\"font-medium\">${ut.name}</td>\n <td class=\"text-secondary\">${ut.description}</td>\n <td>\n <div style=\"display:flex; gap:8px;\">\n <button class=\"btn btn-sm btn-ghost btn-edit-perms\" data-id=\"${ut.id}\">Permissions</button>\n <button class=\"btn btn-sm btn-ghost btn-edit-usertype\" data-id=\"${ut.id}\">Edit</button>\n <button class=\"btn btn-sm btn-icon text-danger btn-delete-usertype\" data-id=\"${ut.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4>Deactivated Users (Cooldown Period)</h4></div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Name</th>\n <th>Role</th>\n <th>Deactivated On</th>\n <th>Cooldown Status</th>\n <th>Actions</th>\n </tr>\n </thead>\n <tbody>\n ${techs.filter(t => t.deactivated).length === 0 ? '<tr><td colspan=\"5\" class=\"text-center text-tertiary\" style=\"padding:24px\">No deactivated users</td></tr>' : ''}\n ${techs.filter(t => t.deactivated).map(t => {\n const deactivatedAt = new Date(t.deactivatedAt);\n const now = new Date();\n const diffTime = now - deactivatedAt;\n const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n const remaining = 30 - diffDays;\n const canReactivate = remaining <= 0;\n\n return `\n <tr>\n <td style=\"opacity:0.6; font-weight:500\">${t.name}</td>\n <td style=\"opacity:0.6\">${t.role}</td>\n <td class=\"text-tertiary\">${deactivatedAt.toLocaleDateString()}</td>\n <td>\n ${canReactivate \n ? '<span class=\"badge badge-success\">Cooldown Complete</span>' \n : `<span class=\"badge badge-warning\" style=\"background:#FFF7ED; color:#C2410C; border:1px solid #FFEDD5\">Available in ${remaining} days</span>`}\n </td>\n <td>\n <button class=\"btn btn-sm btn-ghost btn-reactivate-user\" \n data-id=\"${t.id}\" \n ${!canReactivate ? 'disabled style=\"opacity:0.4; cursor:not-allowed\"' : ''}>\n Reactivate\n </button>\n </td>\n </tr>\n `;\n }).join('')}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-user').addEventListener('click', () => openUserModal());\n tc.querySelectorAll('.btn-edit-user').forEach(btn => {\n btn.addEventListener('click', (e) => openUserModal(e.currentTarget.dataset.id));\n });\n tc.querySelectorAll('.btn-deactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to deactivate <strong>${t.name}</strong>? They will no longer be able to log in.</p>`;\n showModal({\n title: 'Deactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Deactivate', className: 'btn-danger', onClick: c => {\n store.update('technicians', id, { deactivated: true, deactivatedAt: new Date().toISOString() });\n showToast(`${t.name} deactivated`, 'info');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelectorAll('.btn-reactivate-user').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.currentTarget.dataset.id;\n const t = store.getById('technicians', id);\n if (!t) return;\n\n // Extra safety check for cooldown\n const deactivatedAt = new Date(t.deactivatedAt);\n const diffDays = Math.ceil((new Date() - deactivatedAt) / (1000 * 60 * 60 * 24));\n if (diffDays < 30) {\n showToast(`License Policy: Seat cooldown in progress (${30 - diffDays} days remaining)`, 'error');\n return;\n }\n\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Reactivate <strong>${t.name}</strong>? They will regain access once a User Type is assigned.</p>`;\n showModal({\n title: 'Reactivate User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Reactivate', className: 'btn-primary', onClick: c => {\n store.update('technicians', id, {\n deactivated: false,\n deactivatedAt: null\n });\n showToast(`${t.name} has been reactivated.`, 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n\n tc.querySelector('#btn-add-usertype')?.addEventListener('click', () => {\n openUserTypeModal();\n });\n\n tc.querySelectorAll('.btn-edit-perms').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openPermissionsModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-edit-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n openUserTypeModal(e.target.dataset.id);\n });\n });\n\n tc.querySelectorAll('.btn-delete-usertype').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const id = e.target.dataset.id;\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n if (ut.name.toLowerCase().includes('admin')) {\n showToast('Cannot delete the Admin user type — at least one Admin must always exist.', 'error');\n return;\n }\n const usersWithType = store.getAll('technicians').filter(t => t.userTypeId === id);\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `<p>Are you sure you want to delete the user type <strong>${ut.name}</strong>?${usersWithType.length > 0 ? ` <strong>${usersWithType.length} user(s)</strong> will become unassigned.` : ''} This cannot be undone.</p>`;\n showModal({\n title: 'Confirm Deletion',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (c) => c() },\n { label: 'Delete', className: 'btn-danger', onClick: (c) => {\n store.delete('userTypes', id);\n showToast('User Type deleted', 'success');\n c();\n renderContent();\n }}\n ]\n });\n });\n });\n } else if (activeTab === 'materials') {\n renderMaterialsSettings(tc);\n } else if (activeTab === 'tasks') {\n renderTasksSettings(tc);\n } else if (activeTab === 'quote_templates') {\n renderQuoteTemplatesSettings(tc);\n } else if (activeTab === 'tax') {\n const settings = store.getSettings();\n tc.innerHTML = `\n <div class=\"grid-2\">\n <div class=\"card\">\n <div class=\"card-header\"><h4>Tax & Global Markup</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Default Tax Rate (GST)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" type=\"number\" value=\"10\" style=\"width:100px\" disabled /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Global Material Markup</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input class=\"form-input\" id=\"global-markup\" type=\"number\" value=\"${settings.markupPercent || 20}\" style=\"width:100px\" /> <span class=\"text-secondary\">%</span>\n </div>\n </div>\n </div>\n </div>\n <div class=\"card\">\n <div class=\"card-header\"><h4>Labor Rounding Rules</h4></div>\n <div class=\"card-body\">\n <div class=\"form-group\">\n <label class=\"form-label\">Round Technician Time To...</label>\n <select class=\"form-select\" id=\"labor-rounding\">\n <option value=\"1\" ${(settings.laborRounding || 15) === 1 ? 'selected' : ''}>None (Precise)</option>\n <option value=\"5\" ${(settings.laborRounding || 15) === 5 ? 'selected' : ''}>Nearest 5 Minutes</option>\n <option value=\"15\" ${(settings.laborRounding || 15) === 15 ? 'selected' : ''}>Nearest 15 Minutes</option>\n <option value=\"30\" ${(settings.laborRounding || 15) === 30 ? 'selected' : ''}>Nearest 30 Minutes</option>\n <option value=\"60\" ${(settings.laborRounding || 15) === 60 ? 'selected' : ''}>Nearest Hour</option>\n </select>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:8px\">Standardizes billing and ensures technicians are paid consistently for small increments.</p>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\" style=\"display:flex;justify-content:space-between;align-items:center\">\n <div>\n <h4 style=\"margin:0\">Labour Rate Profiles</h4>\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin:4px 0 0\">Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.</p>\n </div>\n <button class=\"btn btn-primary btn-sm\" id=\"add-rate-btn\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Profile\n </button>\n </div>\n <div class=\"card-body\">\n <div id=\"labor-rates-container\" style=\"display:flex;flex-direction:column;gap:16px;\">\n ${settings.laborRates.map((rate) => {\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const applicable = rate.applicableDays || ['Mon','Tue','Wed','Thu','Fri'];\n return `\n <div class=\"labor-rate-card\" data-id=\"${rate.id}\" style=\"border:2px solid ${rate.isDefault ? 'var(--color-primary)' : 'var(--border-color)'}; border-radius:10px; overflow:hidden; background:var(--content-bg);\">\n <!-- Card Header -->\n <div style=\"padding:12px 16px; background:${rate.isDefault ? 'var(--color-primary-light)' : 'var(--bg-color)'}; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:${rate.isDefault ? 'var(--color-primary)' : 'var(--text-tertiary)'}; font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"${rate.name}\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" placeholder=\"Rate Profile Name\" />\n ${rate.isDefault ? '<span class=\"badge\" style=\"background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600\">DEFAULT</span>' : ''}\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${!rate.isDefault ? `<button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${rate.id}\" title=\"Set as default rate\">Set Default</button>` : ''}\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${rate.id}\" title=\"Delete profile\" ${rate.isDefault ? 'disabled style=\"opacity:0.4;cursor:not-allowed\"' : ''}>\n <span class=\"material-icons-outlined\" style=\"font-size:18px;pointer-events:none\">delete</span>\n </button>\n </div>\n </div>\n <!-- Card Body -->\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <!-- Charge-out Rate -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"${rate.rate.toFixed(2)}\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n <span class=\"text-secondary\">/hr</span>\n </div>\n </div>\n <!-- Overtime Multiplier -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Overtime Multiplier</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <input class=\"form-input rate-multiplier\" type=\"number\" value=\"${(rate.overtimeMultiplier || 1).toFixed(1)}\" min=\"1\" max=\"5\" step=\"0.5\" style=\"width:100px\" />\n <span class=\"text-secondary\">× base pay</span>\n </div>\n </div>\n <!-- Minimum Call-out Fee -->\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Min Call-out Fee ($)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"${(rate.minCallOutFee || 0).toFixed(2)}\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n </div>\n <!-- Description -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input rate-desc\" value=\"${rate.description || ''}\" placeholder=\"When is this rate used?\" />\n </div>\n <!-- Applicable Days -->\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${applicable.includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${applicable.includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${applicable.includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${applicable.includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n </div>\n `}).join('')}\n </div>\n </div>\n <div class=\"card-footer\" style=\"display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"save-tax-settings\">\n <span class=\"material-icons-outlined\">save</span> Save All Settings\n </button>\n </div>\n </div>\n\n <div class=\"card\" style=\"margin-top:var(--space-lg)\">\n <div class=\"card-header\"><h4>Job Type Rate Mapping</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:var(--font-size-sm);margin-bottom:16px\">Automatically assign a labor profile when a job of a specific type is created.</p>\n <div style=\"display:grid; grid-template-columns:1fr 1fr; gap:16px\">\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">${type} Default Rate</label>\n <select class=\"form-select rate-mapping\" data-type=\"${type}\">\n <option value=\"\">-- Use Default --</option>\n ${settings.laborRates.map(r => `<option value=\"${r.id}\" ${(settings.rateMappings?.[type] === r.id) ? 'selected' : ''}>${r.name}</option>`).join('')}\n </select>\n </div>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n\n // ---- Day pill toggle ----\n tc.addEventListener('click', (e) => {\n const pill = e.target.closest('.rate-day-pill');\n if (pill) {\n const day = pill.dataset.day;\n const card = pill.closest('.labor-rate-card');\n const chk = card.querySelector(`.rate-day[data-day=\"${day}\"]`);\n chk.checked = !chk.checked;\n const active = chk.checked;\n pill.style.border = `1px solid ${active ? 'var(--color-primary)' : 'var(--border-color)'}`;\n pill.style.background = active ? 'var(--color-primary-light)' : 'transparent';\n pill.style.color = active ? 'var(--color-primary)' : 'var(--text-secondary)';\n }\n });\n\n // ---- Add new profile ----\n tc.querySelector('#add-rate-btn').addEventListener('click', () => {\n const id = 'rate_' + Date.now().toString(36);\n const container = tc.querySelector('#labor-rates-container');\n const allDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun','PH'];\n const dayLabels = { Mon:'Mon', Tue:'Tue', Wed:'Wed', Thu:'Thu', Fri:'Fri', Sat:'Sat', Sun:'Sun', PH:'P.H.' };\n const div = document.createElement('div');\n div.className = \"labor-rate-card\";\n div.dataset.id = id;\n div.style.cssText = \"border:2px solid var(--border-color); border-radius:10px; overflow:hidden; background:var(--content-bg);\";\n div.innerHTML = `\n <div style=\"padding:12px 16px; background:var(--bg-color); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-color);\">\n <div style=\"display:flex;align-items:center;gap:10px;flex:1\">\n <span class=\"material-icons-outlined\" style=\"color:var(--text-tertiary); font-size:20px\">sell</span>\n <input class=\"rate-name\" value=\"New Rate Profile\" style=\"background:transparent;border:none;outline:none;font-weight:600;font-size:15px;color:var(--text-primary);width:200px;\" />\n </div>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <button class=\"btn btn-ghost btn-sm btn-set-default\" data-id=\"${id}\">Set Default</button>\n <button class=\"btn btn-ghost btn-sm btn-icon remove-rate-btn\" data-id=\"${id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </div>\n </div>\n <div style=\"padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:16px;\">\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Charge-out Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:6px\">\n <span style=\"color:var(--text-secondary)\">$</span>\n <input class=\"form-input rate-val\" type=\"number\" value=\"0.00\" min=\"0\" step=\"0.50\" style=\"width:120px\" />\n </div>\n </div>\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Overtime Multiplier</label>\n <input class=\"form-input rate-multiplier\" type=\"number\" value=\"1.0\" min=\"1\" max=\"5\" step=\"0.5\" style=\"width:100px\" />\n </div>\n <div class=\"form-group\" style=\"margin:0\">\n <label class=\"form-label\">Min Call-out Fee ($)</label>\n <input class=\"form-input rate-min-fee\" type=\"number\" value=\"0.00\" min=\"0\" step=\"1.00\" style=\"width:120px\" />\n </div>\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input rate-desc\" value=\"\" />\n </div>\n <div class=\"form-group\" style=\"margin:0;grid-column:1/-1\">\n <label class=\"form-label\">Applicable Days</label>\n <div style=\"display:flex;gap:6px;flex-wrap:wrap;margin-top:4px\">\n ${allDays.map(day => `\n <label style=\"cursor:pointer\">\n <input type=\"checkbox\" class=\"rate-day\" data-day=\"${day}\" ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'checked' : ''} style=\"display:none\" />\n <span class=\"rate-day-pill\" data-day=\"${day}\" style=\"display:inline-block;padding:5px 12px;border-radius:20px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid ${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--border-color)'};background:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary-light)' : 'transparent'};color:${['Mon','Tue','Wed','Thu','Fri'].includes(day) ? 'var(--color-primary)' : 'var(--text-secondary)'}\">\n ${dayLabels[day]}\n </span>\n </label>\n `).join('')}\n </div>\n </div>\n </div>\n `;\n container.appendChild(div);\n });\n\n // ---- Delete profile ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.remove-rate-btn')) {\n const card = e.target.closest('.labor-rate-card');\n if (card) card.remove();\n }\n });\n\n // ---- Set Default (No immediate save) ----\n tc.addEventListener('click', (e) => {\n if (e.target.closest('.btn-set-default')) {\n const targetId = e.target.closest('.btn-set-default').dataset.id;\n const currentRates = _collectRates(tc);\n currentRates.forEach(r => r.isDefault = (r.id === targetId));\n \n // Re-render the container with the new default state WITHOUT saving to store\n const container = tc.querySelector('#labor-rates-container');\n container.innerHTML = currentRates.map((rate) => {\n // ... (I'll just trigger a refresh of the tax tab with the new data)\n // Actually, for simplicity and to avoid losing other unsaved field data, \n // I will just update the UI classes manually.\n tc.querySelectorAll('.labor-rate-card').forEach(card => {\n const isTarget = card.dataset.id === targetId;\n card.style.border = `2px solid ${isTarget ? 'var(--color-primary)' : 'var(--border-color)'}`;\n const header = card.querySelector('div[style*=\"padding:12px 16px\"]');\n if (header) header.style.background = isTarget ? 'var(--color-primary-light)' : 'var(--bg-color)';\n \n // Toggle badge\n let badge = card.querySelector('.badge');\n if (isTarget && !badge) {\n const nameContainer = card.querySelector('div[style*=\"flex:1\"]');\n const b = document.createElement('span');\n b.className = 'badge';\n b.style.cssText = 'background:var(--color-primary);color:#fff;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600';\n b.textContent = 'DEFAULT';\n nameContainer.appendChild(b);\n } else if (!isTarget && badge) {\n badge.remove();\n }\n \n // Toggle button\n let setDefBtn = card.querySelector('.btn-set-default');\n if (isTarget && setDefBtn) {\n setDefBtn.remove();\n } else if (!isTarget && !setDefBtn) {\n const actions = card.querySelector('div[style*=\"gap:8px\"]');\n const b = document.createElement('button');\n b.className = 'btn btn-ghost btn-sm btn-set-default';\n b.dataset.id = card.dataset.id;\n b.textContent = 'Set Default';\n actions.prepend(b);\n }\n });\n });\n showToast('Default rate updated in view. Click Save to apply.', 'info');\n }\n });\n\n // Helper to collect all rates from the UI\n function _collectRates(container) {\n return Array.from(container.querySelectorAll('.labor-rate-card')).map(card => {\n const id = card.dataset.id;\n const name = card.querySelector('.rate-name').value;\n const rate = parseFloat(card.querySelector('.rate-val').value) || 0;\n const multiplier = parseFloat(card.querySelector('.rate-multiplier').value) || 1;\n const desc = card.querySelector('.rate-desc').value;\n const minFee = parseFloat(card.querySelector('.rate-min-fee').value) || 0;\n const isDefault = card.querySelector('.btn-set-default') === null;\n const applicableDays = Array.from(card.querySelectorAll('.rate-day:checked')).map(chk => chk.dataset.day);\n \n return { id, name, rate, description: desc, overtimeMultiplier: multiplier, minCallOutFee: minFee, applicableDays, isDefault };\n });\n }\n\n // ---- Save ----\n tc.querySelector('#save-tax-settings').addEventListener('click', () => {\n const markupPercent = parseFloat(tc.querySelector('#global-markup').value) || 0;\n const laborRounding = parseInt(tc.querySelector('#labor-rounding').value) || 15;\n const laborRates = _collectRates(tc);\n const settings = store.getSettings();\n settings.markupPercent = markupPercent;\n settings.laborRounding = laborRounding;\n settings.laborRates = laborRates;\n \n // Save rate mappings\n settings.rateMappings = {};\n tc.querySelectorAll('.rate-mapping').forEach(sel => {\n if (sel.value) settings.rateMappings[sel.dataset.type] = sel.value;\n });\n\n store.saveSettings(settings);\n showToast('Financial and Rate settings saved', 'success');\n renderContent();\n });\n\n } else if (activeTab === 'assets') {\n const settings = store.getSettings();\n const assets = store.getAll('assets').filter(a => a.category === 'Business');\n \n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:800px\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Business Asset Defaults</h4>\n <div class=\"badge badge-info\">Recovery Automation Enabled</div>\n </div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px; margin-bottom:20px\">Configure the default recovery rates for your business equipment. These rates are used to calculate internal job costs when assets are assigned to tasks.</p>\n \n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Asset Name</th>\n <th>Current Rate ($/hr)</th>\n <th>Status</th>\n </tr>\n </thead>\n <tbody>\n ${assets.map(a => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(a.name)}</td>\n <td>\n <div style=\"display:flex; align-items:center; gap:8px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input asset-rate-input\" data-id=\"${a.id}\" value=\"${a.recoveryRate || 0}\" step=\"0.5\" style=\"width:100px; height:32px\" />\n </div>\n </td>\n <td><span class=\"badge badge-success\">Active</span></td>\n </tr>\n `).join('')}\n ${assets.length === 0 ? '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:24px\">No business assets found. Add assets in the main Assets module.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n <div class=\"card-footer\">\n <button class=\"btn btn-primary\" id=\"btn-save-asset-settings\">Save Asset Recovery Rates</button>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-save-asset-settings').addEventListener('click', () => {\n tc.querySelectorAll('.asset-rate-input').forEach(inp => {\n const id = inp.dataset.id;\n const rate = parseFloat(inp.value) || 0;\n store.update('assets', id, { recoveryRate: rate });\n });\n showToast('Asset recovery rates updated across the system', 'success');\n });\n\n } else if (activeTab === 'system') {\n tc.innerHTML = `\n <div class=\"card\" style=\"max-width:480px\">\n <div class=\"card-header\"><h4>Data Management</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"margin-bottom:var(--space-lg)\">Manage your application data. All data is stored locally in your browser.</p>\n <div style=\"display:flex;flex-direction:column;gap:12px\">\n <button class=\"btn btn-secondary\" id=\"btn-reset-data\">\n <span class=\"material-icons-outlined\">refresh</span> Reset to Demo Data\n </button>\n <button class=\"btn btn-danger\" id=\"btn-clear-data\">\n <span class=\"material-icons-outlined\">delete_forever</span> Clear All Data\n </button>\n </div>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-reset-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('Data reset. Reloading...', 'info');\n setTimeout(() => window.location.reload(), 1000);\n });\n\n tc.querySelector('#btn-clear-data')?.addEventListener('click', () => {\n store.clearAll();\n showToast('All data cleared. Reloading...', 'warning');\n setTimeout(() => window.location.reload(), 1000);\n });\n }\n }\n\n function openUserTypeModal(editId = null) {\n let ut = editId ? store.getById('userTypes', editId) : { name: '', description: '', template: 'Admin' };\n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n ${!editId ? `\n <div class=\"form-group\">\n <label class=\"form-label\">Template (Auto-fills permissions)</label>\n <select class=\"form-select\" id=\"ut-template\">\n <option value=\"Admin\">Admin</option>\n <option value=\"Manager\">Manager</option>\n <option value=\"Technician\">Technician</option>\n <option value=\"Office Staff\">Office Staff</option>\n <option value=\"Custom\">Custom</option>\n </select>\n <button class=\"btn btn-secondary mt-2\" id=\"ut-custom-edit-perms\" style=\"display:none; width:100%; justify-content:center; align-items:center; gap:8px;\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px;\">edit</span> Configure Custom Permissions\n </button>\n </div>\n ` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">User Type Name</label>\n <input class=\"form-input\" id=\"ut-name\" value=\"${ut.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input class=\"form-input\" id=\"ut-desc\" value=\"${ut.description}\" />\n </div>\n `;\n\n const templateSelect = contentDiv.querySelector('#ut-template');\n const customEditBtn = contentDiv.querySelector('#ut-custom-edit-perms');\n\n if (templateSelect && customEditBtn) {\n templateSelect.addEventListener('change', (e) => {\n if (e.target.value === 'Custom') {\n customEditBtn.style.display = 'flex';\n } else {\n customEditBtn.style.display = 'none';\n }\n });\n \n customEditBtn.addEventListener('click', () => {\n const name = contentDiv.querySelector('#ut-name').value;\n const desc = contentDiv.querySelector('#ut-desc').value;\n if (!name) {\n showToast('Please enter a User Type Name first', 'error');\n return;\n }\n \n const perms = buildGranularPerms(() => false);\n const newUt = store.create('userTypes', { name, description: desc, permissions: perms });\n \n document.getElementById('modal-close-btn')?.click();\n openPermissionsModal(newUt.id);\n });\n }\n\n showModal({\n title: editId ? 'Edit User Type' : 'Add User Type',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('ut-name').value;\n const desc = document.getElementById('ut-desc').value;\n const template = document.getElementById('ut-template')?.value;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('userTypes', editId, { name, description: desc });\n } else {\n const baseModules = ['Dashboard', 'Customers', 'Leads', 'Quotes', 'Jobs', 'Timesheets', 'Assets', 'Schedule', 'Contractors', 'Stock', 'Purchase Orders', 'Invoices', 'Documents', 'Reports', 'Settings'];\n let perms = [];\n if (template === 'Admin') perms = buildGranularPerms(() => true);\n else if (template === 'Manager') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return ['view', 'edit_company'].includes(key);\n return true;\n });\n else if (template === 'Technician') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Dashboard') return key === 'view';\n if (mod === 'Jobs') return ['view', 'manage_tasks', 'book_time'].includes(key);\n if (mod === 'Timesheets') return ['view_own', 'create'].includes(key);\n if (mod === 'Schedule') return ['view_own'].includes(key);\n return false;\n });\n else if (template === 'Office Staff') perms = buildGranularPerms((mod, key) => {\n if (mod === 'Settings') return false;\n if (mod === 'Reports') return key === 'view';\n if (['Invoices', 'Purchase Orders'].includes(mod) && key === 'delete') return false;\n return true;\n });\n else perms = buildGranularPerms(() => false);\n\n store.create('userTypes', { name, description: desc, permissions: perms });\n }\n showToast('User Type saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openPermissionsModal(id) {\n const ut = store.getById('userTypes', id);\n if (!ut) return;\n\n const existingPerms = ut.permissions || [];\n const permsMap = {};\n existingPerms.forEach(p => { permsMap[p.module] = p; });\n\n const contentDiv = document.createElement('div');\n\n const moduleSections = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const existing = permsMap[module] || {};\n const allChecked = permDefs.every(({ key }) => existing[key]);\n const permCheckboxes = permDefs.map(({ key, label }) => `\n <label style=\"display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px; padding:4px 0\">\n <input type=\"checkbox\" class=\"perm-chk\" data-module=\"${module}\" data-key=\"${key}\" ${existing[key] ? 'checked' : ''}\n style=\"width:15px;height:15px;cursor:pointer\" />\n <span>${label}</span>\n </label>\n `).join('');\n return `\n <div style=\"border:1px solid var(--border-color); border-radius:6px; overflow:hidden; margin-bottom:8px\">\n <div style=\"padding:8px 14px; background:var(--content-bg); display:flex; align-items:center; justify-content:space-between\">\n <span style=\"font-weight:600; font-size:13px\">${module}</span>\n <label style=\"display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--text-secondary)\">\n <input type=\"checkbox\" class=\"module-select-all\" data-module=\"${module}\" ${allChecked ? 'checked' : ''}\n style=\"width:14px;height:14px;cursor:pointer\" />\n Select All\n </label>\n </div>\n <div style=\"padding:10px 16px; display:grid; grid-template-columns:1fr 1fr; gap:2px\">\n ${permCheckboxes}\n </div>\n </div>\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n <div style=\"display:flex; gap:8px; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-color)\">\n <button id=\"btn-select-all-perms\" class=\"btn btn-sm btn-ghost\">Select All</button>\n <button id=\"btn-deselect-all-perms\" class=\"btn btn-sm btn-ghost\">Deselect All</button>\n </div>\n <div style=\"max-height:62vh; overflow-y:auto; padding-right:4px\">\n ${moduleSections}\n </div>\n `;\n\n contentDiv.querySelector('#btn-select-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = true);\n });\n contentDiv.querySelector('#btn-deselect-all-perms').addEventListener('click', () => {\n contentDiv.querySelectorAll('.perm-chk, .module-select-all').forEach(c => c.checked = false);\n });\n contentDiv.querySelectorAll('.module-select-all').forEach(toggle => {\n toggle.addEventListener('change', (e) => {\n const mod = e.target.dataset.module;\n contentDiv.querySelectorAll(`.perm-chk[data-module=\"${mod}\"]`).forEach(c => c.checked = e.target.checked);\n });\n });\n contentDiv.querySelectorAll('.perm-chk').forEach(chk => {\n chk.addEventListener('change', () => {\n const mod = chk.dataset.module;\n const defs = MODULE_PERMS[mod] || [];\n const allChecked = defs.every(({ key }) => {\n const c = contentDiv.querySelector(`.perm-chk[data-module=\"${mod}\"][data-key=\"${key}\"]`);\n return c && c.checked;\n });\n const toggle = contentDiv.querySelector(`.module-select-all[data-module=\"${mod}\"]`);\n if (toggle) toggle.checked = allChecked;\n });\n });\n\n showModal({\n title: `Edit Permissions: ${ut.name}`,\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save Permissions', className: 'btn-primary', onClick: c => {\n const newPerms = Object.entries(MODULE_PERMS).map(([module, permDefs]) => {\n const obj = { module };\n permDefs.forEach(({ key }) => {\n const chk = contentDiv.querySelector(`.perm-chk[data-module=\"${module}\"][data-key=\"${key}\"]`);\n obj[key] = chk ? chk.checked : false;\n });\n return obj;\n });\n store.update('userTypes', id, { permissions: newPerms });\n showToast('Permissions updated successfully', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n function openUserModal(editId = null) {\n let t = editId ? store.getById('technicians', editId) : { name: '', role: '', color: '#1B6DE0', email: '', userTypeId: '' };\n const userTypes = store.getAll('userTypes');\n \n const contentDiv = document.createElement('div');\n contentDiv.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Name</label>\n <input class=\"form-input\" id=\"u-name\" value=\"${t.name}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Email</label>\n <input class=\"form-input\" id=\"u-email\" value=\"${t.email || ''}\" />\n </div>\n <div class=\"form-row\">\n <div class=\"form-group\">\n <label class=\"form-label\">Role / Job Title</label>\n <input class=\"form-input\" id=\"u-role\" value=\"${t.role}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">User Type</label>\n <select class=\"form-select\" id=\"u-type\">\n <option value=\"\">-- Select --</option>\n ${userTypes.map(ut => `\n <option value=\"${ut.id}\" ${t.userTypeId === ut.id ? 'selected' : ''}>${ut.name}</option>\n `).join('')}\n </select>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Pay Rate ($/hr)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span style=\"color:var(--text-secondary);font-size:15px\">$</span>\n <input class=\"form-input\" id=\"u-payrate\" type=\"number\" min=\"0\" step=\"0.50\" value=\"${t.payRate || ''}\" placeholder=\"e.g. 45.00\" style=\"width:140px\" />\n <span class=\"text-secondary\" style=\"font-size:var(--font-size-sm)\">/hr — used in job cost &amp; P&amp;L calculations</span>\n </div>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Profile Color</label>\n <div style=\"display:flex; align-items:center; gap:8px; flex-wrap:wrap;\">\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n <div class=\"color-swatch\" data-color=\"${c}\" style=\"width:28px; height:28px; border-radius:50%; background:${c}; cursor:pointer; border:2px solid ${t.color.toUpperCase() === c.toUpperCase() ? 'var(--text-primary)' : 'transparent'}; box-shadow:0 1px 2px rgba(0,0,0,0.1)\"></div>\n `).join('')}\n <div style=\"position:relative; width:28px; height:28px; cursor:pointer; border-radius:50%; background:#f3f5f9; display:flex; align-items:center; justify-content:center; border:1px solid var(--border-color); margin-left:8px;\" title=\"Custom Color\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px; color:var(--text-secondary)\">colorize</span>\n <input type=\"color\" id=\"u-color\" value=\"${t.color}\" style=\"position:absolute; opacity:0; width:100%; height:100%; cursor:pointer; left:0; top:0;\" />\n </div>\n </div>\n </div>\n `;\n\n const colorInput = contentDiv.querySelector('#u-color');\n const swatches = contentDiv.querySelectorAll('.color-swatch');\n\n swatches.forEach(sw => {\n sw.addEventListener('click', () => {\n colorInput.value = sw.dataset.color;\n swatches.forEach(s => s.style.borderColor = 'transparent');\n sw.style.borderColor = 'var(--text-primary)';\n });\n });\n\n colorInput.addEventListener('input', () => {\n swatches.forEach(s => s.style.borderColor = 'transparent');\n });\n\n showModal({\n title: editId ? 'Edit User' : 'Add User',\n content: contentDiv,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: c => c() },\n { label: 'Save', className: 'btn-primary', onClick: c => {\n const name = document.getElementById('u-name').value;\n const email = document.getElementById('u-email').value;\n const role = document.getElementById('u-role').value;\n const userTypeId = document.getElementById('u-type').value;\n const color = document.getElementById('u-color').value;\n const payRate = parseFloat(document.getElementById('u-payrate').value) || null;\n \n if (!name) { showToast('Name required', 'error'); return; }\n \n if (editId) {\n store.update('technicians', editId, { name, email, role, userTypeId, color, payRate });\n } else {\n store.create('technicians', { name, email, role, userTypeId, color, payRate });\n }\n showToast('User saved', 'success');\n renderContent();\n c();\n }}\n ]\n });\n }\n\n document.addEventListener('save-settings', () => showToast('Settings saved', 'success'));\n\n function renderTasksSettings(tc) {\n const templates = store.getAll('taskTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Tasklist Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th>Tags</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\" style=\"max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis\">${escapeHTML(t.description || '—')}</td>\n <td>\n <div style=\"display:flex; gap:4px; flex-wrap:wrap\">\n ${(t.tags || []).map(tag => `<span class=\"badge badge-neutral\" style=\"font-size:10px\">${escapeHTML(tag)}</span>`).join('')}\n </div>\n </td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"4\" class=\"text-center text-tertiary\" style=\"padding:32px\">No templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-template').addEventListener('click', () => {\n openEditTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('taskTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditTemplateModal(editId = null) {\n const t = editId ? store.getById('taskTemplates', editId) : { name: '', description: '', tags: [], tasks: [] };\n const content = document.createElement('div');\n content.style.maxHeight = '80vh';\n content.style.overflowY = 'auto';\n content.style.padding = '4px';\n \n // Deep clone local tasks and normalize legacy '.phases' / '.subPhases' / '.tasks'\n let localTasks = JSON.parse(JSON.stringify(t.tasks || t.phases || [])).map(p => {\n // Normalize name\n if (!p.subTasks && p.subPhases) {\n p.subTasks = p.subPhases;\n delete p.subPhases;\n }\n if (p.tasks && !p.subTasks) {\n p.subTasks = p.tasks.map(task => ({\n id: task.id || store.generateId(),\n name: task.name || '',\n estimatedHours: task.estimatedHours || 0,\n people: task.people || 1,\n status: 'Not Started',\n progress: 0\n }));\n delete p.tasks;\n }\n \n // Deep sub-tasks check helper\n function normalizeSubTasks(node) {\n if (node.subPhases && !node.subTasks) {\n node.subTasks = node.subPhases;\n delete node.subPhases;\n }\n if (!node.subTasks) node.subTasks = [];\n node.subTasks.forEach(normalizeSubTasks);\n }\n normalizeSubTasks(p);\n return p;\n });\n\n let taskExpandedPath = localTasks.length > 0 ? [0] : [];\n let taskViewPath = [];\n let isInfoPanelEditing = false;\n\n function getTaskByPath(tasks, path) {\n if (!path || path.length === 0) return null;\n let curr = tasks[path[0]];\n if (!curr) return null;\n for (let i = 1; i < path.length; i++) {\n if (!curr.subTasks) return null;\n curr = curr.subTasks[path[i]];\n if (!curr) return null;\n }\n return curr;\n }\n\n function calculateTotalHours(node) {\n if (!node.subTasks || node.subTasks.length === 0) {\n return (parseFloat(node.estimatedHours) || 0) * (parseInt(node.people) || 1);\n }\n return node.subTasks.reduce((sum, sp) => sum + calculateTotalHours(sp), 0);\n }\n\n const renderTemplateEditor = () => {\n content.innerHTML = `\n <div class=\"grid-3\" style=\"margin-bottom:16px; gap:16px\">\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name *</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-desc\" value=\"${escapeHTML(t.description || '')}\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Tags (comma separated)</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-tmpl-tags\" value=\"${(t.tags || []).join(', ')}\" />\n </div>\n </div>\n\n <div style=\"display:flex; gap:16px; min-height:380px; align-items:stretch\">\n <!-- Left panel: Drill-Down List -->\n ${(() => {\n const viewParentNode = taskViewPath.length > 0 ? getTaskByPath(localTasks, taskViewPath) : null;\n const viewList = viewParentNode ? (viewParentNode.subTasks || []) : localTasks;\n const viewTitle = viewParentNode ? escapeHTML(viewParentNode.name) : 'Main Tasks';\n \n return `\n <div style=\"flex: 0 0 280px; display:flex; flex-direction:column; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg);\">\n <div style=\"padding:10px; border-bottom:1px solid var(--border-color); font-weight:600; display:flex; justify-content:space-between; align-items:center\">\n <div style=\"display:flex; align-items:center; gap:6px; overflow:hidden\">\n ${taskViewPath.length > 0 ? `<button class=\"btn btn-ghost btn-sm btn-icon btn-view-back\" title=\"Back\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">arrow_back</span></button>` : ''}\n <span style=\"white-space:nowrap; overflow:hidden; text-overflow:ellipsis\" title=\"${viewTitle}\">${viewTitle}</span>\n </div>\n <button class=\"btn btn-ghost btn-sm btn-icon btn-add-node\" title=\"Add Task\" style=\"padding:2px; min-width:24px; min-height:24px\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">add</span></button>\n </div>\n <div style=\"padding:6px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; flex:1\">\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n <div class=\"tmpl-task-list-item\" data-path=\"${currentPath.join('-')}\" style=\"padding:8px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; ${isSelected ? 'background:var(--color-primary-light); color:var(--color-primary)' : 'background:var(--bg-color)'}\">\n <span style=\"font-weight:${isSelected ? '600' : '400'}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1;\" title=\"${escapeHTML(p.name)}\">${escapeHTML(p.name)}</span>\n ${p.subTasks && p.subTasks.length > 0 ? `<button class=\"btn btn-ghost btn-icon btn-sm btn-drill-down-tmpl\" data-path=\"${currentPath.join('-')}\" style=\"margin-left:6px; padding:2px; min-width:20px; min-height:20px; color:inherit\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">chevron_right</span></button>` : ''}\n </div>\n `;\n }).join('')}\n ${viewList.length === 0 ? '<div style=\"color:var(--text-tertiary);font-size:12px;text-align:center;padding:12px\">No items. Click + to add.</div>' : ''}\n </div>\n </div>\n `;\n })()}\n\n <!-- Right panel: Task Details Form -->\n <div style=\"flex:1; border:1px solid var(--border-color); border-radius:4px; background:var(--content-bg); padding:16px; display:flex; flex-direction:column\">\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Selected task not found.</div>';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n ${!isInfoPanelEditing ? `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:60%\" title=\"${escapeHTML(node.name)}\">${escapeHTML(node.name)}</h4>\n <div style=\"display:flex; gap:6px\">\n ${path.length < 3 ? `<button class=\"btn btn-xs btn-secondary btn-add-child-tmpl\" data-path=\"${path.join('-')}\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">add</span> Add Sub-task</button>` : ''}\n <button class=\"btn btn-xs btn-primary btn-edit-info-tmpl\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">edit</span> Edit</button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Name</div>\n <div style=\"font-size:14px; font-weight:500\">${escapeHTML(node.name)}</div>\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:14px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div style=\"display:flex; gap:16px; margin-bottom:12px\">\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Estimated Hours</div>\n <div style=\"font-size:14px; font-weight:500\">${node.estimatedHours || 0} hrs</div>\n </div>\n <div>\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">People</div>\n <div style=\"font-size:14px; font-weight:500\">${node.people || 1}</div>\n </div>\n </div>\n `}\n <div style=\"margin-top:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Description</div>\n <div style=\"font-size:13px; white-space:pre-wrap; color:var(--text-secondary)\">${escapeHTML(node.description || 'No description provided.')}</div>\n </div>\n ` : `\n <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px\">\n <h4 style=\"margin:0\">Edit Item Details</h4>\n <div style=\"display:flex; gap:6px\">\n <button class=\"btn btn-xs btn-primary btn-done-info-tmpl\">Done</button>\n <button class=\"btn btn-xs btn-secondary btn-duplicate-task-tmpl\" data-path=\"${path.join('-')}\" title=\"Duplicate\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">content_copy</span></button>\n <button class=\"btn btn-xs btn-danger btn-remove-task-tmpl-item\" data-path=\"${path.join('-')}\" title=\"Delete\"><span class=\"material-icons-outlined\" style=\"font-size:14px\">delete</span> Delete</button>\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-bottom:12px\">\n <label class=\"form-label\" style=\"font-size:11px\">Name *</label>\n <input type=\"text\" class=\"form-input tmpl-detail-input\" data-field=\"name\" value=\"${escapeHTML(node.name)}\" style=\"font-size:13px\" />\n </div>\n ${hasSubs ? `\n <div style=\"margin-bottom:12px\">\n <div style=\"font-size:11px; color:var(--text-tertiary); margin-bottom:2px\">Total Hours (Rollup)</div>\n <div style=\"font-size:13px; font-weight:500\">${calculateTotalHours(node)} hrs</div>\n </div>\n ` : `\n <div class=\"form-row\" style=\"margin-bottom:12px; gap:8px\">\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">Est. Hours</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"estimatedHours\" value=\"${node.estimatedHours || ''}\" min=\"0\" step=\"0.5\" style=\"font-size:13px\" />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\" style=\"font-size:11px\">People</label>\n <input type=\"number\" class=\"form-input tmpl-detail-input\" data-field=\"people\" value=\"${node.people || '1'}\" min=\"1\" step=\"1\" style=\"font-size:13px\" />\n </div>\n </div>\n `}\n <div class=\"form-group\" style=\"margin-bottom:0\">\n <label class=\"form-label\" style=\"font-size:11px\">Description</label>\n <textarea class=\"form-input tmpl-detail-input\" data-field=\"description\" rows=\"3\" style=\"font-size:13px\">${escapeHTML(node.description || '')}</textarea>\n </div>\n `}\n `;\n })() : '<div class=\"text-tertiary text-center\" style=\"margin:auto\">Add or select a task on the left to edit details.</div>'}\n </div>\n </div>\n `;\n\n // 1. Back button for drill-down view\n content.querySelector('.btn-view-back')?.addEventListener('click', () => {\n taskViewPath.pop();\n renderTemplateEditor();\n });\n\n // 2. Drill-down button\n content.querySelectorAll('.btn-drill-down-tmpl').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n taskViewPath = btn.dataset.path.split('-').map(Number);\n taskExpandedPath = [...taskViewPath];\n renderTemplateEditor();\n });\n });\n\n // 3. Selection of items\n content.querySelectorAll('.tmpl-task-list-item').forEach(item => {\n item.addEventListener('click', (e) => {\n if (e.target.closest('.btn-drill-down-tmpl')) return;\n taskExpandedPath = item.dataset.path.split('-').map(Number);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n });\n\n // 4. Add node at current drill-down level\n content.querySelector('.btn-add-node')?.addEventListener('click', () => {\n const newNode = {\n id: store.generateId(),\n name: 'New Task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n };\n if (taskViewPath.length === 0) {\n localTasks.push(newNode);\n taskExpandedPath = [localTasks.length - 1];\n } else {\n const parent = getTaskByPath(localTasks, taskViewPath);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push(newNode);\n taskExpandedPath = [...taskViewPath, parent.subTasks.length - 1];\n }\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 5. Add sub-task\n content.querySelector('.btn-add-child-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const parent = getTaskByPath(localTasks, path);\n if (!parent.subTasks) parent.subTasks = [];\n parent.subTasks.push({\n id: store.generateId(),\n name: 'New Sub-task',\n status: 'Not Started',\n progress: 0,\n estimatedHours: 0,\n people: 1,\n subTasks: []\n });\n taskExpandedPath = [...path, parent.subTasks.length - 1];\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 6. Edit Details button\n content.querySelector('.btn-edit-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = true;\n renderTemplateEditor();\n });\n\n // 7. Done button\n content.querySelector('.btn-done-info-tmpl')?.addEventListener('click', () => {\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n\n // 8. Live input changes in info panel\n content.querySelectorAll('.tmpl-detail-input').forEach(inp => {\n inp.addEventListener('input', (e) => {\n const node = getTaskByPath(localTasks, taskExpandedPath);\n if (!node) return;\n const field = e.target.dataset.field;\n if (field === 'estimatedHours') {\n node[field] = parseFloat(e.target.value) || 0;\n } else if (field === 'people') {\n node[field] = parseInt(e.target.value) || 1;\n } else {\n node[field] = e.target.value;\n }\n });\n });\n\n // 9. Remove item\n content.querySelectorAll('.btn-remove-task-tmpl-item').forEach(btn => {\n btn.addEventListener('click', (e) => {\n const path = btn.dataset.path.split('-').map(Number);\n if (confirm('Are you sure you want to delete this item and all its sub-tasks?')) {\n if (path.length === 1) {\n localTasks.splice(path[0], 1);\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n if (parent && parent.subTasks) {\n parent.subTasks.splice(path[path.length - 1], 1);\n }\n }\n taskExpandedPath = path.slice(0, -1);\n isInfoPanelEditing = false;\n renderTemplateEditor();\n }\n });\n });\n\n // 10. Duplicate item\n content.querySelector('.btn-duplicate-task-tmpl')?.addEventListener('click', (e) => {\n const path = e.currentTarget.dataset.path.split('-').map(Number);\n const nodeToCopy = getTaskByPath(localTasks, path);\n if (!nodeToCopy) return;\n\n function cloneNode(node, isRootCopy) {\n return {\n ...node,\n id: store.generateId(),\n name: node.name + (isRootCopy ? ' (Copy)' : ''),\n status: 'Not Started',\n progress: 0,\n subTasks: node.subTasks ? node.subTasks.map(c => cloneNode(c, false)) : []\n };\n }\n\n const cloned = cloneNode(nodeToCopy, true);\n if (path.length === 1) {\n localTasks.splice(path[0] + 1, 0, cloned);\n taskExpandedPath = [path[0] + 1];\n } else {\n const parentPath = path.slice(0, -1);\n const parent = getTaskByPath(localTasks, parentPath);\n parent.subTasks.splice(path[path.length - 1] + 1, 0, cloned);\n taskExpandedPath = [...parentPath, path[path.length - 1] + 1];\n }\n isInfoPanelEditing = false;\n renderTemplateEditor();\n });\n };\n\n renderTemplateEditor();\n\n showModal({\n title: editId ? 'Edit Tasklist Template' : 'Create Tasklist Template',\n content,\n size: 'modal-lg',\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-tmpl-name').value;\n const description = content.querySelector('#edit-tmpl-desc').value;\n const tags = content.querySelector('#edit-tmpl-tags').value.split(',').map(tag => tag.trim()).filter(Boolean);\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n // Save BOTH tasks and phases for maximum compatibility\n const templateData = { name, description, tags, tasks: localTasks, phases: localTasks };\n if (editId) {\n store.update('taskTemplates', editId, templateData);\n } else {\n store.create('taskTemplates', templateData);\n }\n showToast('Tasklist template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderQuoteTemplatesSettings(tc) {\n const templates = store.getAll('quoteTemplates');\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center;\">\n <h4 style=\"margin:0\">Quote Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-quote-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create Template\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Template Name</th>\n <th>Description</th>\n <th style=\"text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.length ? templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td class=\"text-secondary\">${escapeHTML(t.description || '—')}</td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-sm btn-icon btn-edit-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">edit</span></button>\n <button class=\"btn btn-ghost btn-sm btn-icon text-danger btn-delete-quote-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\" style=\"font-size:18px\">delete</span></button>\n </td>\n </tr>\n `).join('') : '<tr><td colspan=\"3\" class=\"text-center text-tertiary\" style=\"padding:32px\">No quote templates saved yet.</td></tr>'}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-quote-template').addEventListener('click', () => {\n openEditQuoteTemplateModal();\n });\n\n tc.querySelectorAll('.btn-delete-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Delete this template?')) {\n store.delete('quoteTemplates', btn.dataset.id);\n renderContent();\n }\n });\n });\n\n tc.querySelectorAll('.btn-edit-quote-template').forEach(btn => {\n btn.addEventListener('click', () => {\n openEditQuoteTemplateModal(btn.dataset.id);\n });\n });\n\n function openEditQuoteTemplateModal(editId = null) {\n const t = editId ? store.getById('quoteTemplates', editId) : { name: '', description: '', sections: [] };\n const content = document.createElement('div');\n content.innerHTML = `\n <div class=\"form-group\">\n <label class=\"form-label\">Template Name</label>\n <input type=\"text\" class=\"form-input\" id=\"edit-qtmpl-name\" value=\"${escapeHTML(t.name)}\" required />\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Description</label>\n <textarea class=\"form-input\" id=\"edit-qtmpl-desc\" rows=\"3\">${escapeHTML(t.description || '')}</textarea>\n </div>\n `;\n\n showModal({\n title: editId ? 'Edit Quote Template' : 'Create Quote Template',\n content,\n actions: [\n { label: 'Cancel', className: 'btn-secondary', onClick: (close) => close() },\n { label: 'Save Template', className: 'btn-primary', onClick: (close) => {\n const name = content.querySelector('#edit-qtmpl-name').value;\n const description = content.querySelector('#edit-qtmpl-desc').value;\n\n if (!name) { showToast('Name required', 'error'); return; }\n\n if (editId) {\n store.update('quoteTemplates', editId, { name, description });\n } else {\n store.create('quoteTemplates', { name, description, sections: [] });\n }\n showToast('Quote template saved', 'success');\n close();\n renderContent();\n }}\n ]\n });\n }\n }\n\n function renderMaterialsSettings(tc) {\n const settings = store.getSettings();\n const markup = settings.materialMarkup || { defaultPercent: 30, minMarkupAmount: 0, useTiers: false, tiers: [] };\n const categories = settings.materialCategories || ['General'];\n\n tc.innerHTML = `\n <div style=\"max-width:900px\">\n <div class=\"card\" style=\"margin-bottom:24px\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Markup Configuration</h4></div>\n <div class=\"card-body\">\n <div class=\"grid-2\">\n <div class=\"form-group\">\n <label class=\"form-label\">Global Default Markup (%)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <input type=\"number\" class=\"form-input\" id=\"mat-default-markup\" value=\"${markup.defaultPercent}\" style=\"width:100px\" />\n <span class=\"text-secondary\">%</span>\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Applied to items not covered by tiers or categories.</p>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\">Minimum Markup Amount ($)</label>\n <div style=\"display:flex;align-items:center;gap:8px\">\n <span class=\"text-secondary\">$</span>\n <input type=\"number\" class=\"form-input\" id=\"mat-min-markup\" value=\"${markup.minMarkupAmount}\" step=\"0.50\" style=\"width:100px\" />\n </div>\n <p class=\"text-tertiary\" style=\"font-size:12px;margin-top:4px\">Ensures a base profit on even the smallest components.</p>\n </div>\n </div>\n\n <div style=\"margin-top:24px; padding-top:24px; border-top:1px solid var(--border-color)\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px\">\n <div>\n <h5 style=\"margin:0\">Tiered Pricing</h5>\n <p class=\"text-secondary\" style=\"font-size:12px;margin:4px 0 0 0\">Automatically adjust markup based on the unit cost of the item.</p>\n </div>\n <label style=\"display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer\">\n <input type=\"checkbox\" id=\"mat-use-tiers\" ${markup.useTiers ? 'checked' : ''} /> Enable Tiers\n </label>\n </div>\n\n <div id=\"tiers-container\" style=\"display:flex;flex-direction:column;gap:8px; ${markup.useTiers ? '' : 'opacity:0.5;pointer-events:none'}\">\n <table class=\"data-table\" style=\"font-size:13px\">\n <thead>\n <tr>\n <th>Item Cost Range</th>\n <th style=\"width:120px\">Markup %</th>\n <th style=\"width:40px\"></th>\n </tr>\n </thead>\n <tbody id=\"tier-rows\">\n ${(markup.tiers || []).map((t, i) => `\n <tr>\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n ${i === 0 ? 'Up to' : 'From previous up to'} \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"${t.upTo || ''}\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"${t.percent}\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\" data-idx=\"${i}\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n </tbody>\n </table>\n <button class=\"btn btn-secondary btn-sm\" id=\"btn-add-tier\" style=\"align-self:flex-start;margin-top:8px\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Add Pricing Tier\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"card\">\n <div class=\"card-header\"><h4 style=\"margin:0\">Material Categories</h4></div>\n <div class=\"card-body\">\n <p class=\"text-secondary\" style=\"font-size:13px;margin-bottom:16px\">Group items for reporting and bulk adjustments.</p>\n <div style=\"display:flex;flex-wrap:wrap;gap:8px\" id=\"categories-container\">\n ${categories.map(c => `\n <div class=\"badge badge-neutral\" style=\"padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px\">\n ${c}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${c}\" style=\"font-size:14px;cursor:pointer\">close</span>\n </div>\n `).join('')}\n <button class=\"btn btn-outline btn-sm\" id=\"btn-add-category\" style=\"border-style:dashed\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> New Category\n </button>\n </div>\n </div>\n </div>\n\n <div style=\"margin-top:24px;display:flex;justify-content:flex-end\">\n <button class=\"btn btn-primary\" id=\"btn-save-materials\">Save Material Settings</button>\n </div>\n </div>\n `;\n\n // --- Handlers ---\n const save = () => {\n const defaultPercent = parseFloat(tc.querySelector('#mat-default-markup').value);\n const minMarkupAmount = parseFloat(tc.querySelector('#mat-min-markup').value);\n const useTiers = tc.querySelector('#mat-use-tiers').checked;\n \n const tiers = Array.from(tc.querySelectorAll('#tier-rows tr')).map(tr => ({\n upTo: parseFloat(tr.querySelector('.tier-upto').value) || null,\n percent: parseFloat(tr.querySelector('.tier-percent').value) || 0\n })).sort((a, b) => (a.upTo === null ? 1 : (b.upTo === null ? -1 : a.upTo - b.upTo)));\n\n const categories = Array.from(tc.querySelectorAll('.btn-remove-cat')).map(span => span.dataset.name);\n\n const updatedSettings = {\n ...settings,\n materialMarkup: { defaultPercent, minMarkupAmount, useTiers, tiers },\n materialCategories: categories\n };\n \n store.saveSettings(updatedSettings);\n showToast('Material settings saved', 'success');\n };\n\n tc.querySelector('#mat-use-tiers').addEventListener('change', (e) => {\n tc.querySelector('#tiers-container').style.opacity = e.target.checked ? '1' : '0.5';\n tc.querySelector('#tiers-container').style.pointerEvents = e.target.checked ? 'auto' : 'none';\n });\n\n tc.querySelector('#btn-add-tier').addEventListener('click', () => {\n const row = document.createElement('tr');\n row.innerHTML = `\n <td>\n <div style=\"display:flex;align-items:center;gap:8px\">\n From previous up to \n <div style=\"display:flex;align-items:center;gap:4px\">\n <span class=\"text-tertiary\">$</span>\n <input type=\"number\" class=\"form-input tier-upto\" value=\"\" placeholder=\"Infinity\" style=\"height:28px;padding:2px 8px;width:100px\" />\n </div>\n </div>\n </td>\n <td>\n <div style=\"display:flex;align-items:center;gap:4px\">\n <input type=\"number\" class=\"form-input tier-percent\" value=\"20\" style=\"height:28px;padding:2px 8px;width:80px\" />\n <span class=\"text-tertiary\">%</span>\n </div>\n </td>\n <td>\n <button class=\"btn btn-icon btn-sm text-danger btn-remove-tier\"><span class=\"material-icons-outlined\" style=\"font-size:16px\">delete</span></button>\n </td>\n `;\n tc.querySelector('#tier-rows').appendChild(row);\n row.querySelector('.btn-remove-tier').addEventListener('click', () => row.remove());\n });\n\n tc.querySelectorAll('.btn-remove-tier').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('tr').remove());\n });\n\n tc.querySelector('#btn-add-category').addEventListener('click', () => {\n const name = prompt('Enter category name:');\n if (name) {\n const btn = document.createElement('div');\n btn.className = 'badge badge-neutral';\n btn.style.cssText = 'padding:8px 12px;font-size:13px;display:flex;align-items:center;gap:8px';\n btn.innerHTML = `\n ${name}\n <span class=\"material-icons-outlined btn-remove-cat\" data-name=\"${name}\" style=\"font-size:14px;cursor:pointer\">close</span>\n `;\n tc.querySelector('#categories-container').insertBefore(btn, tc.querySelector('#btn-add-category'));\n btn.querySelector('.btn-remove-cat').addEventListener('click', () => btn.remove());\n }\n });\n\n tc.querySelectorAll('.btn-remove-cat').forEach(btn => {\n btn.addEventListener('click', () => btn.closest('.badge').remove());\n });\n\n tc.querySelector('#btn-save-materials').addEventListener('click', save);\n }\n\n render();\n}\n function renderFormsTab(tc) {\n const templates = store.getAll('formTemplates');\n\n tc.innerHTML = `\n <div class=\"card\">\n <div class=\"card-header\" style=\"display:flex; justify-content:space-between; align-items:center\">\n <h4 style=\"margin:0\">Custom Form Templates</h4>\n <button class=\"btn btn-primary btn-sm\" id=\"btn-add-form-template\">\n <span class=\"material-icons-outlined\" style=\"font-size:16px\">add</span> Create New Form\n </button>\n </div>\n <div class=\"card-body\" style=\"padding:0\">\n <div style=\"padding:16px; font-size:13px; color:var(--text-tertiary); border-bottom:1px solid var(--border-color)\">\n Create reusable forms that can be attached to jobs for technicians to fill out in the field (e.g. Safety Audits, Site Inspections).\n </div>\n <table class=\"data-table\">\n <thead>\n <tr>\n <th>Form Name</th>\n <th>Description</th>\n <th>Fields</th>\n <th style=\"width:100px; text-align:right\">Actions</th>\n </tr>\n </thead>\n <tbody>\n ${templates.map(t => `\n <tr>\n <td class=\"font-medium\">${escapeHTML(t.name)}</td>\n <td style=\"color:var(--text-secondary); font-size:13px\">${escapeHTML(t.description || '—')}</td>\n <td><span class=\"badge badge-neutral\">${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields</span></td>\n <td style=\"text-align:right\">\n <button class=\"btn btn-ghost btn-icon btn-sm edit-form-template\" data-id=\"${t.id}\"><span class=\"material-icons-outlined\">edit</span></button>\n <button class=\"btn btn-ghost btn-icon btn-sm delete-form-template\" data-id=\"${t.id}\" style=\"color:var(--color-danger)\"><span class=\"material-icons-outlined\">delete</span></button>\n </td>\n </tr>\n `).join('')}\n ${!templates.length ? '<tr><td colspan=\"4\" style=\"text-align:center; padding:40px; color:var(--text-tertiary)\">No form templates created yet.</td></tr>' : ''}\n </tbody>\n </table>\n </div>\n </div>\n `;\n\n tc.querySelector('#btn-add-form-template').addEventListener('click', () => router.navigate('/settings/forms/new'));\n \n tc.querySelectorAll('.edit-form-template').forEach(btn => {\n btn.addEventListener('click', () => router.navigate(`/settings/forms/${btn.dataset.id}/edit`));\n });\n\n tc.querySelectorAll('.delete-form-template').forEach(btn => {\n btn.addEventListener('click', () => {\n if (confirm('Are you sure you want to delete this form template? Existing job forms based on this template will remain but no new ones can be created.')) {\n const id = btn.dataset.id;\n const filtered = store.getAll('formTemplates').filter(t => t.id !== id);\n store.save('formTemplates', filtered);\n renderFormsTab(tc);\n }\n });\n });\n }\n"}}}}