{"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(`DISPATCH`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`DISPATCH`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`DISPATCH`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`DISPATCH`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`DISPATCH`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`DISPATCH`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`DISPATCH`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`DISPATCH`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`DISPATCH`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`DISPATCH`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`DISPATCH`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`DISPATCH`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`DISPATCH`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`DISPATCH`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`DISPATCH`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`RELAY— Dispatch`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`RELAY— Dispatch`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`RELAY— Dispatch`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`RELAY— Dispatch`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`RELAY— Dispatch`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`RELAY— Dispatch`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`RELAY— Dispatch`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`RELAY— Dispatch`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`RELAY— Dispatch`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(``)}`;\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(`RELAY— Dispatch`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`;\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(`RELAY— Dispatch`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`;\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(`RELAY— Dispatch`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`;\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(`RELAY— Dispatch`)}`;\n\nexport const RELAY_LOGO_SMALL_SVG = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`;\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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 \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 \n `;\n }).join('');\n\n container.innerHTML = `\n \n\n

Settings

\n\n
\n ${categoryTabsHtml}\n
\n\n
\n ${subTabsHtml}\n
\n\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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 = 'sync 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
\n \n info\n Your company login code is ${companySlug}. Technicians log in using username@${companySlug}.\n \n
\n\n
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeUsernamePay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n ${ut?.id !== 'ut_admin' ? `\n \n ` : ''}\n
\n
\n
\n
\n\n
\n
\n

Pending Password Reset Requests

\n
\n
\n \n \n \n \n \n \n \n \n \n \n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '' : ''}\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 \n \n \n \n \n \n `;\n }).join('')}\n \n
NameUsername/EmailRequested OnActions
No pending reset requests
${tech.name || 'Unknown'}${r.employee_id || tech.username || ''}${requestedAt}\n
\n \n \n
\n
\n
\n
\n\n ${isLocalMode ? '' : `\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n `}\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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

Approve password reset request for ${t.name}? Please enter their new password below:

\n
\n \n \n
\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n
\n

Tax Rates

\n
\n
\n \n
\n %\n
\n
\n
\n
\n\n
\n

Job Types

\n
\n

Define the types of jobs your team performs (e.g. Electrical, Plumbing). These appear in job forms and rate mapping.

\n
\n ${currentJobTypes.map(type => `\n \n ${escapeHTML(type)}\n close\n \n `).join('')}\n
\n
\n \n \n
\n
\n
\n
\n\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => {\n const active = applicable.includes(day);\n return `\n \n `}).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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 \n ${escapeHTML(type)}\n close\n \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 = 'sync 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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n

Contractor Portal Settings

\n
\n
\n \n
\n Enable Contractor Portal Link Access\n When disabled, any subcontractor attempting to load their portal token will see an access deactivated notice.\n
\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n \n
\n \n
\n

Data Management

\n
\n

\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n

\n
\n ${currentUser.role === 'admin' ? `\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n

\n \n \n \n\n ${isLocalMode ? `\n
\n
\n warning Irreversible Profile Conversions\n
\n

\n Convert your deployment type. These transitions are permanent and one-way.\n

\n ${localDeploymentType === 'single_user' ? `\n \n ` : ''}\n \n
\n ` : ''}\n
\n ` : '
Administrative actions are restricted to company administrators.
'}\n
\n
\n
\n\n \n ${!isLocalMode && currentUser.role === 'admin' ? `\n
\n
\n backup\n

Local Data Backup

\n
\n
\n

\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n

\n
\n
\n
\n ` : ''}\n
\n\n \n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n \n \n

\n Select a color profile and visual background effect for your user interface.\n

\n
\n
\n
\n
\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

You are about to seed a complete demonstration dataset.

\n
\n info\n 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.\n
\n

This is highly recommended for testing and walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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

WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)

\n
\n warning\n You are about to permanently delete the entire company profile for ${escapeHTML(store.getSettings().name || '')} and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).\n
\n

\n This action is final, irreversible, and cannot be undone under any circumstances.\n

\n

Do you want to proceed to password confirmation?

\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
\n

FINAL CONFIRMATION (2 of 2)

\n
\n gavel\n To authorize the permanent destruction of this company, you must enter the Administrator password.\n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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 = 'sync 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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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
\n Browser Local Folder Access Unsupported\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n
\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n
\n folder_off\n
No backup folder configured.
\n
\n \n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n
\n Permission Required
\n Access permission to ${escapeHTML(store.backupDirHandle.name)} has expired. Please re-authorize.\n
\n
\n \n \n
\n `;\n } else {\n bsc.innerHTML = `\n
\n Backup Configured
\n Folder: ${escapeHTML(store.backupDirHandle.name)}
\n Last Backup: ${formattedLastBackup}\n
\n
\n \n
\n \n \n
\n
\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 = 'sync 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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n ${!editId ? `\n
\n Company login code is: ${companySlug}. User will log in with username@${companySlug}\n
\n ` : ''}\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 = 'sync 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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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
\n

Online Payments (Stripe)

\n
\n

Online card payments are a cloud feature. Upgrade to a cloud account to let customers pay invoices online by card.

\n
\n
`;\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
\n

Online Payments (Stripe)

\n
\n

\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

\n\n
\n
One-time setup
\n
    \n
  1. Create a Stripe account and copy your Secret key.
  2. \n
  3. In Supabase → Edge Functions → Secrets, add STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET.
  4. \n
  5. Deploy the relay-create-payment and relay-stripe-webhook functions.
  6. \n
  7. In Stripe → Developers → Webhooks, add an endpoint pointing at relay-stripe-webhook and subscribe it to checkout.session.completed.
  8. \n
\n
\n\n
\n \n
\n
Payments configured & live
\n
Tick once the setup above is done. Controls whether the Pay Link button appears on invoices.
\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n
\n
\n
`;\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 = 'sync 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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n
\n error_outline\n Browser Directory Access Unsupported\n
\n

\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

\n
\n `;\n } else if (!isEnabled) {\n statusHtml = `\n
\n folder_off\n
\n
Folder Synchronization Inactive
\n

All data is currently stored in your browser's private IndexedDB database sandbox.

\n
\n
\n `;\n } else if (isCapacitor) {\n statusHtml = `\n
\n cloud_done\n
\n
Capacitor Direct Folder Sync Active
\n

\n Data is synchronizing directly to the application's native Documents/RelayDispatchData directory on your iPad/device.\n

\n
\n
\n `;\n } else if (!hasHandle) {\n statusHtml = `\n
\n warning\n
\n
No Directory Selected
\n

Please select a directory folder on your computer to begin syncing.

\n
\n
\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n
\n
\n lock\n
\n
Access Permission Suspended
\n

\n The browser requires re-authorization to read/write files in ${escapeHTML(store.dirHandle.name)}.\n

\n
\n
\n \n
\n `;\n } else {\n statusHtml = `\n
\n check_circle\n
\n
Folder Sync Active & Synchronized
\n

\n Active Folder: ${escapeHTML(store.dirHandle.name)}. All data edits are writing dynamically.\n

\n
\n
\n `;\n }\n\n tc.innerHTML = `\n
\n \n
\n

Direct Directory Sync

\n
\n ${statusHtml}\n\n
\n \n \n
\n\n ${isEnabled && isSupported && !isCapacitor ? `\n
\n \n ${hasHandle && isPermissionGranted ? `\n \n ${!isLocalCompany ? `\n \n ` : ''}\n ` : ''}\n
\n ` : ''}\n
\n
\n\n \n
\n

How Self-Hosting Works

\n
\n

\n By linking a local folder, you establish a serverless self-hosted data hub.\n

\n
\n storage\n Readable Data: Your jobs, quotes, and company configurations are saved in data/*.json files.\n
\n
\n photo_library\n Physical Attachments: Uploaded photos and PDF catalogs are saved as standard image/PDF files under the documents/ subfolder.\n
\n
\n sync_alt\n Cloud Backups: Select a folder synced to OneDrive, Google Drive, or Dropbox to automatically back up and sync your data to the cloud.\n
\n
\n
\n
\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
\n
\n
\n

AI Assistant Configuration

\n
\n \n
\n \n
\n\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n \n
\n
\n
${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.'}
\n\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \n \n
Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.
\n
\n\n
\n \n \n
\n
\n
\n\n
\n \n
\n\n
\n
\n\n
\n

Google Maps Configuration

\n
\n
\n \n \n
Used for address autocomplete, distance calculations, and rendering maps. Get this from the Google Cloud Console.
\n
\n
\n \n
\n
\n
\n\n
\n\n \n
\n

Cheapest Smart AI Guide

\n
\n

\n DeepSeek is one of the most cost-efficient and high-performance language models currently available, making it perfect for lightweight co-pilot tasks.\n

\n
\n key\n API Key: Get your key from platform.deepseek.com. New accounts typically receive free trial credits.\n
\n
\n security\n CORS Restrictions: Due to browser security, direct API calls to DeepSeek may be blocked. To bypass this:\n
    \n
  • Route via a CORS proxy (e.g. https://cors-anywhere.herokuapp.com/ prefix).
  • \n
  • Connect via an aggregator like OpenRouter (API Endpoint: https://openrouter.ai/api/v1/chat/completions).
  • \n
  • Use a local LLM runner like Ollama (API Endpoint: http://localhost:11434/v1/chat/completions).
  • \n
\n
\n
\n
\n
\n\n
\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
\n
\n

Cost Centers

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n ${list.length === 0 ? `\n \n \n \n ` : list.map(cc => `\n \n \n \n \n \n \n \n `).join('')}\n \n
CodeNameXero MappingStatusActions
\n No Cost Centers configured. Click \"Add Cost Center\" to create one.\n
${escapeHTML(cc.code)}${escapeHTML(cc.name)}\n ${cc.xeroSalesAccountCode || cc.xeroTrackingOptionName ? `\n
\n ${cc.xeroSalesAccountCode ? `
Sales Code: ${escapeHTML(cc.xeroSalesAccountCode)}
` : ''}\n ${cc.xeroExpenseAccountCode ? `
Expense Code: ${escapeHTML(cc.xeroExpenseAccountCode)}
` : ''}\n ${cc.xeroTrackingOptionName ? `
Tracking: ${escapeHTML(cc.xeroTrackingCategoryName || 'Department')}:${escapeHTML(cc.xeroTrackingOptionName)}
` : ''}\n
\n ` : '— Unmapped —'}\n
\n \n ${cc.active ? 'Active' : 'Inactive'}\n \n \n \n \n
\n
\n
\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
\n \n \n
\n
\n \n \n
\n \n
\n \n sync Xero Integration (Optional)\n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n\n
\n \n
\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
\n
\n

Supplier Categories

\n
\n

Define classifications/categories for your suppliers (e.g. Electrical, Plumbing, HVAC). These categories are used to group suppliers in the Suppliers directory.

\n
\n ${categories.map(c => `\n
\n ${escapeHTML(c)}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 close\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 = 'sync 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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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 \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 \n `;\n }).join('');\n\n container.innerHTML = `\n \n\n

Settings

\n\n
\n ${categoryTabsHtml}\n
\n\n
\n ${subTabsHtml}\n
\n\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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 = 'sync 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
\n \n info\n Your company login code is ${companySlug}. Technicians log in using username@${companySlug}.\n \n
\n\n
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeUsernamePay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n ${ut?.id !== 'ut_admin' ? `\n \n ` : ''}\n
\n
\n
\n
\n\n
\n
\n

Pending Password Reset Requests

\n
\n
\n \n \n \n \n \n \n \n \n \n \n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '' : ''}\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 \n \n \n \n \n \n `;\n }).join('')}\n \n
NameUsername/EmailRequested OnActions
No pending reset requests
${tech.name || 'Unknown'}${r.employee_id || tech.username || ''}${requestedAt}\n
\n \n \n
\n
\n
\n
\n\n ${isLocalMode ? '' : `\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n `}\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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

Approve password reset request for ${t.name}? Please enter their new password below:

\n
\n \n \n
\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n
\n

Tax Rates

\n
\n
\n \n
\n %\n
\n
\n
\n
\n\n
\n

Job Types

\n
\n

Define the types of jobs your team performs (e.g. Electrical, Plumbing). These appear in job forms and rate mapping.

\n
\n ${currentJobTypes.map(type => `\n \n ${escapeHTML(type)}\n close\n \n `).join('')}\n
\n
\n \n \n
\n
\n
\n
\n\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => {\n const active = applicable.includes(day);\n return `\n \n `}).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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 \n ${escapeHTML(type)}\n close\n \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 = 'sync 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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n

Contractor Portal Settings

\n
\n
\n \n
\n Enable Contractor Portal Link Access\n When disabled, any subcontractor attempting to load their portal token will see an access deactivated notice.\n
\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n \n
\n \n
\n

Data Management

\n
\n

\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n

\n
\n ${currentUser.role === 'admin' ? `\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n

\n \n \n \n\n ${isLocalMode ? `\n
\n
\n warning Irreversible Profile Conversions\n
\n

\n Convert your deployment type. These transitions are permanent and one-way.\n

\n ${localDeploymentType === 'single_user' ? `\n \n ` : ''}\n \n
\n ` : ''}\n
\n ` : '
Administrative actions are restricted to company administrators.
'}\n
\n
\n
\n\n \n ${!isLocalMode && currentUser.role === 'admin' ? `\n
\n
\n backup\n

Local Data Backup

\n
\n
\n

\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n

\n
\n
\n
\n ` : ''}\n
\n\n \n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n \n \n

\n Select a color profile and visual background effect for your user interface.\n

\n
\n
\n
\n
\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

You are about to seed a complete demonstration dataset.

\n
\n info\n 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.\n
\n

This is highly recommended for testing and walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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

WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)

\n
\n warning\n You are about to permanently delete the entire company profile for ${escapeHTML(store.getSettings().name || '')} and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).\n
\n

\n This action is final, irreversible, and cannot be undone under any circumstances.\n

\n

Do you want to proceed to password confirmation?

\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
\n

FINAL CONFIRMATION (2 of 2)

\n
\n gavel\n To authorize the permanent destruction of this company, you must enter the Administrator password.\n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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 = 'sync 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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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
\n Browser Local Folder Access Unsupported\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n
\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n
\n folder_off\n
No backup folder configured.
\n
\n \n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n
\n Permission Required
\n Access permission to ${escapeHTML(store.backupDirHandle.name)} has expired. Please re-authorize.\n
\n
\n \n \n
\n `;\n } else {\n bsc.innerHTML = `\n
\n Backup Configured
\n Folder: ${escapeHTML(store.backupDirHandle.name)}
\n Last Backup: ${formattedLastBackup}\n
\n
\n \n
\n \n \n
\n
\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 = 'sync 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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n ${!editId ? `\n
\n Company login code is: ${companySlug}. User will log in with username@${companySlug}\n
\n ` : ''}\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 = 'sync 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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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
\n

Online Payments (Stripe)

\n
\n

Online card payments are a cloud feature. Upgrade to a cloud account to let customers pay invoices online by card.

\n
\n
`;\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
\n

Online Payments (Stripe)

\n
\n

\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

\n\n
\n
One-time setup
\n
    \n
  1. Create a Stripe account and copy your Secret key.
  2. \n
  3. In Supabase → Edge Functions → Secrets, add STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET.
  4. \n
  5. Deploy the relay-create-payment and relay-stripe-webhook functions.
  6. \n
  7. In Stripe → Developers → Webhooks, add an endpoint pointing at relay-stripe-webhook and subscribe it to checkout.session.completed.
  8. \n
\n
\n\n
\n \n
\n
Payments configured & live
\n
Tick once the setup above is done. Controls whether the Pay Link button appears on invoices.
\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n
\n
\n
`;\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 = 'sync 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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n
\n error_outline\n Browser Directory Access Unsupported\n
\n

\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

\n
\n `;\n } else if (!isEnabled) {\n statusHtml = `\n
\n folder_off\n
\n
Folder Synchronization Inactive
\n

All data is currently stored in your browser's private IndexedDB database sandbox.

\n
\n
\n `;\n } else if (isCapacitor) {\n statusHtml = `\n
\n cloud_done\n
\n
Capacitor Direct Folder Sync Active
\n

\n Data is synchronizing directly to the application's native Documents/RelayDispatchData directory on your iPad/device.\n

\n
\n
\n `;\n } else if (!hasHandle) {\n statusHtml = `\n
\n warning\n
\n
No Directory Selected
\n

Please select a directory folder on your computer to begin syncing.

\n
\n
\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n
\n
\n lock\n
\n
Access Permission Suspended
\n

\n The browser requires re-authorization to read/write files in ${escapeHTML(store.dirHandle.name)}.\n

\n
\n
\n \n
\n `;\n } else {\n statusHtml = `\n
\n check_circle\n
\n
Folder Sync Active & Synchronized
\n

\n Active Folder: ${escapeHTML(store.dirHandle.name)}. All data edits are writing dynamically.\n

\n
\n
\n `;\n }\n\n tc.innerHTML = `\n
\n \n
\n

Direct Directory Sync

\n
\n ${statusHtml}\n\n
\n \n \n
\n\n ${isEnabled && isSupported && !isCapacitor ? `\n
\n \n ${hasHandle && isPermissionGranted ? `\n \n ${!isLocalCompany ? `\n \n ` : ''}\n ` : ''}\n
\n ` : ''}\n
\n
\n\n \n
\n

How Self-Hosting Works

\n
\n

\n By linking a local folder, you establish a serverless self-hosted data hub.\n

\n
\n storage\n Readable Data: Your jobs, quotes, and company configurations are saved in data/*.json files.\n
\n
\n photo_library\n Physical Attachments: Uploaded photos and PDF catalogs are saved as standard image/PDF files under the documents/ subfolder.\n
\n
\n sync_alt\n Cloud Backups: Select a folder synced to OneDrive, Google Drive, or Dropbox to automatically back up and sync your data to the cloud.\n
\n
\n
\n
\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
\n
\n
\n

AI Assistant Configuration

\n
\n \n
\n \n
\n\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n \n
\n
\n
${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.'}
\n\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \n \n
Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.
\n
\n\n
\n \n \n
\n
\n
\n\n
\n \n
\n\n
\n
\n\n
\n

Google Maps Configuration

\n
\n
\n \n \n
Used for address autocomplete, distance calculations, and rendering maps. Get this from the Google Cloud Console.
\n
\n
\n \n
\n
\n
\n\n
\n\n \n
\n

Cheapest Smart AI Guide

\n
\n

\n DeepSeek is one of the most cost-efficient and high-performance language models currently available, making it perfect for lightweight co-pilot tasks.\n

\n
\n key\n API Key: Get your key from platform.deepseek.com. New accounts typically receive free trial credits.\n
\n
\n security\n CORS Restrictions: Due to browser security, direct API calls to DeepSeek may be blocked. To bypass this:\n
    \n
  • Route via a CORS proxy (e.g. https://cors-anywhere.herokuapp.com/ prefix).
  • \n
  • Connect via an aggregator like OpenRouter (API Endpoint: https://openrouter.ai/api/v1/chat/completions).
  • \n
  • Use a local LLM runner like Ollama (API Endpoint: http://localhost:11434/v1/chat/completions).
  • \n
\n
\n
\n
\n
\n\n
\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
\n
\n

Cost Centers

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n ${list.length === 0 ? `\n \n \n \n ` : list.map(cc => `\n \n \n \n \n \n \n \n `).join('')}\n \n
CodeNameXero MappingStatusActions
\n No Cost Centers configured. Click \"Add Cost Center\" to create one.\n
${escapeHTML(cc.code)}${escapeHTML(cc.name)}\n ${cc.xeroSalesAccountCode || cc.xeroTrackingOptionName ? `\n
\n ${cc.xeroSalesAccountCode ? `
Sales Code: ${escapeHTML(cc.xeroSalesAccountCode)}
` : ''}\n ${cc.xeroExpenseAccountCode ? `
Expense Code: ${escapeHTML(cc.xeroExpenseAccountCode)}
` : ''}\n ${cc.xeroTrackingOptionName ? `
Tracking: ${escapeHTML(cc.xeroTrackingCategoryName || 'Department')}:${escapeHTML(cc.xeroTrackingOptionName)}
` : ''}\n
\n ` : '— Unmapped —'}\n
\n \n ${cc.active ? 'Active' : 'Inactive'}\n \n \n \n \n
\n
\n
\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
\n \n \n
\n
\n \n \n
\n \n
\n \n sync Xero Integration (Optional)\n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n\n
\n \n
\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
\n
\n

Supplier Categories

\n
\n

Define classifications/categories for your suppliers (e.g. Electrical, Plumbing, HVAC). These categories are used to group suppliers in the Suppliers directory.

\n
\n ${categories.map(c => `\n
\n ${escapeHTML(c)}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 close\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 = 'sync 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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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 \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 \n `;\n }).join('');\n\n container.innerHTML = `\n \n\n

Settings

\n\n
\n ${categoryTabsHtml}\n
\n\n
\n ${subTabsHtml}\n
\n\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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 = 'sync 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
\n \n info\n Your company login code is ${companySlug}. Technicians log in using username@${companySlug}.\n \n
\n\n
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeUsernamePay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n ${ut?.id !== 'ut_admin' ? `\n \n ` : ''}\n
\n
\n
\n
\n\n
\n
\n

Pending Password Reset Requests

\n
\n
\n \n \n \n \n \n \n \n \n \n \n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '' : ''}\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 \n \n \n \n \n \n `;\n }).join('')}\n \n
NameUsername/EmailRequested OnActions
No pending reset requests
${tech.name || 'Unknown'}${r.employee_id || tech.username || ''}${requestedAt}\n
\n \n \n
\n
\n
\n
\n\n ${isLocalMode ? '' : `\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n `}\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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

Approve password reset request for ${t.name}? Please enter their new password below:

\n
\n \n \n
\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n
\n

Tax Rates

\n
\n
\n \n
\n %\n
\n
\n
\n
\n\n
\n

Job Types

\n
\n

Define the types of jobs your team performs (e.g. Electrical, Plumbing). These appear in job forms and rate mapping.

\n
\n ${currentJobTypes.map(type => `\n \n ${escapeHTML(type)}\n close\n \n `).join('')}\n
\n
\n \n \n
\n
\n
\n
\n\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => {\n const active = applicable.includes(day);\n return `\n \n `}).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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 \n ${escapeHTML(type)}\n close\n \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 = 'sync 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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n

Contractor Portal Settings

\n
\n
\n \n
\n Enable Contractor Portal Link Access\n When disabled, any subcontractor attempting to load their portal token will see an access deactivated notice.\n
\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n \n
\n \n
\n

Data Management

\n
\n

\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n

\n
\n ${currentUser.role === 'admin' ? `\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n

\n \n \n \n\n ${isLocalMode ? `\n
\n
\n warning Irreversible Profile Conversions\n
\n

\n Convert your deployment type. These transitions are permanent and one-way.\n

\n ${localDeploymentType === 'single_user' ? `\n \n ` : ''}\n \n
\n ` : ''}\n
\n ` : '
Administrative actions are restricted to company administrators.
'}\n
\n
\n
\n\n \n ${!isLocalMode && currentUser.role === 'admin' ? `\n
\n
\n backup\n

Local Data Backup

\n
\n
\n

\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n

\n
\n
\n
\n ` : ''}\n
\n\n \n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n \n \n

\n Select a color profile and visual background effect for your user interface.\n

\n
\n
\n
\n
\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

You are about to seed a complete demonstration dataset.

\n
\n info\n 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.\n
\n

This is highly recommended for testing and walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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

WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)

\n
\n warning\n You are about to permanently delete the entire company profile for ${escapeHTML(store.getSettings().name || '')} and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).\n
\n

\n This action is final, irreversible, and cannot be undone under any circumstances.\n

\n

Do you want to proceed to password confirmation?

\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
\n

FINAL CONFIRMATION (2 of 2)

\n
\n gavel\n To authorize the permanent destruction of this company, you must enter the Administrator password.\n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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 = 'sync 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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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
\n Browser Local Folder Access Unsupported\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n
\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n
\n folder_off\n
No backup folder configured.
\n
\n \n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n
\n Permission Required
\n Access permission to ${escapeHTML(store.backupDirHandle.name)} has expired. Please re-authorize.\n
\n
\n \n \n
\n `;\n } else {\n bsc.innerHTML = `\n
\n Backup Configured
\n Folder: ${escapeHTML(store.backupDirHandle.name)}
\n Last Backup: ${formattedLastBackup}\n
\n
\n \n
\n \n \n
\n
\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 = 'sync 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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n ${!editId ? `\n
\n Company login code is: ${companySlug}. User will log in with username@${companySlug}\n
\n ` : ''}\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 = 'sync 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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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
\n

Online Payments (Stripe)

\n
\n

Online card payments are a cloud feature. Upgrade to a cloud account to let customers pay invoices online by card.

\n
\n
`;\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
\n

Online Payments (Stripe)

\n
\n

\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

\n\n
\n
One-time setup
\n
    \n
  1. Create a Stripe account and copy your Secret key.
  2. \n
  3. In Supabase → Edge Functions → Secrets, add STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET.
  4. \n
  5. Deploy the relay-create-payment and relay-stripe-webhook functions.
  6. \n
  7. In Stripe → Developers → Webhooks, add an endpoint pointing at relay-stripe-webhook and subscribe it to checkout.session.completed.
  8. \n
\n
\n\n
\n \n
\n
Payments configured & live
\n
Tick once the setup above is done. Controls whether the Pay Link button appears on invoices.
\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n
\n
\n
`;\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 = 'sync 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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n
\n error_outline\n Browser Directory Access Unsupported\n
\n

\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

\n
\n `;\n } else if (!isEnabled) {\n statusHtml = `\n
\n folder_off\n
\n
Folder Synchronization Inactive
\n

All data is currently stored in your browser's private IndexedDB database sandbox.

\n
\n
\n `;\n } else if (isCapacitor) {\n statusHtml = `\n
\n cloud_done\n
\n
Capacitor Direct Folder Sync Active
\n

\n Data is synchronizing directly to the application's native Documents/RelayDispatchData directory on your iPad/device.\n

\n
\n
\n `;\n } else if (!hasHandle) {\n statusHtml = `\n
\n warning\n
\n
No Directory Selected
\n

Please select a directory folder on your computer to begin syncing.

\n
\n
\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n
\n
\n lock\n
\n
Access Permission Suspended
\n

\n The browser requires re-authorization to read/write files in ${escapeHTML(store.dirHandle.name)}.\n

\n
\n
\n \n
\n `;\n } else {\n statusHtml = `\n
\n check_circle\n
\n
Folder Sync Active & Synchronized
\n

\n Active Folder: ${escapeHTML(store.dirHandle.name)}. All data edits are writing dynamically.\n

\n
\n
\n `;\n }\n\n tc.innerHTML = `\n
\n \n
\n

Direct Directory Sync

\n
\n ${statusHtml}\n\n
\n \n \n
\n\n ${isEnabled && isSupported && !isCapacitor ? `\n
\n \n ${hasHandle && isPermissionGranted ? `\n \n ${!isLocalCompany ? `\n \n ` : ''}\n ` : ''}\n
\n ` : ''}\n
\n
\n\n \n
\n

How Self-Hosting Works

\n
\n

\n By linking a local folder, you establish a serverless self-hosted data hub.\n

\n
\n storage\n Readable Data: Your jobs, quotes, and company configurations are saved in data/*.json files.\n
\n
\n photo_library\n Physical Attachments: Uploaded photos and PDF catalogs are saved as standard image/PDF files under the documents/ subfolder.\n
\n
\n sync_alt\n Cloud Backups: Select a folder synced to OneDrive, Google Drive, or Dropbox to automatically back up and sync your data to the cloud.\n
\n
\n
\n
\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
\n
\n
\n

AI Assistant Configuration

\n
\n \n
\n \n
\n\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n \n
\n
\n
${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.'}
\n\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \n \n
Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.
\n
\n\n
\n \n \n
\n
\n
\n\n
\n \n
\n\n
\n
\n\n
\n

Google Maps Configuration

\n
\n
\n \n \n
Used for address autocomplete, distance calculations, and rendering maps. Get this from the Google Cloud Console.
\n
\n
\n \n
\n
\n
\n\n
\n\n \n
\n

Cheapest Smart AI Guide

\n
\n

\n DeepSeek is one of the most cost-efficient and high-performance language models currently available, making it perfect for lightweight co-pilot tasks.\n

\n
\n key\n API Key: Get your key from platform.deepseek.com. New accounts typically receive free trial credits.\n
\n
\n security\n CORS Restrictions: Due to browser security, direct API calls to DeepSeek may be blocked. To bypass this:\n
    \n
  • Route via a CORS proxy (e.g. https://cors-anywhere.herokuapp.com/ prefix).
  • \n
  • Connect via an aggregator like OpenRouter (API Endpoint: https://openrouter.ai/api/v1/chat/completions).
  • \n
  • Use a local LLM runner like Ollama (API Endpoint: http://localhost:11434/v1/chat/completions).
  • \n
\n
\n
\n
\n
\n\n
\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
\n
\n

Cost Centers

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n ${list.length === 0 ? `\n \n \n \n ` : list.map(cc => `\n \n \n \n \n \n \n \n `).join('')}\n \n
CodeNameXero MappingStatusActions
\n No Cost Centers configured. Click \"Add Cost Center\" to create one.\n
${escapeHTML(cc.code)}${escapeHTML(cc.name)}\n ${cc.xeroSalesAccountCode || cc.xeroTrackingOptionName ? `\n
\n ${cc.xeroSalesAccountCode ? `
Sales Code: ${escapeHTML(cc.xeroSalesAccountCode)}
` : ''}\n ${cc.xeroExpenseAccountCode ? `
Expense Code: ${escapeHTML(cc.xeroExpenseAccountCode)}
` : ''}\n ${cc.xeroTrackingOptionName ? `
Tracking: ${escapeHTML(cc.xeroTrackingCategoryName || 'Department')}:${escapeHTML(cc.xeroTrackingOptionName)}
` : ''}\n
\n ` : '— Unmapped —'}\n
\n \n ${cc.active ? 'Active' : 'Inactive'}\n \n \n \n \n
\n
\n
\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
\n \n \n
\n
\n \n \n
\n \n
\n \n sync Xero Integration (Optional)\n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n\n
\n \n
\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
\n
\n

Supplier Categories

\n
\n

Define classifications/categories for your suppliers (e.g. Electrical, Plumbing, HVAC). These categories are used to group suppliers in the Suppliers directory.

\n
\n ${categories.map(c => `\n
\n ${escapeHTML(c)}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 close\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 = 'sync 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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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 \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 \n `;\n }).join('');\n\n container.innerHTML = `\n \n\n

Settings

\n\n
\n ${categoryTabsHtml}\n
\n\n
\n ${subTabsHtml}\n
\n\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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 = 'sync 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
\n \n info\n Your company login code is ${companySlug}. Technicians log in using username@${companySlug}.\n \n
\n\n
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeUsernamePay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n ${ut?.id !== 'ut_admin' ? `\n \n ` : ''}\n
\n
\n
\n
\n\n
\n
\n

Pending Password Reset Requests

\n
\n
\n \n \n \n \n \n \n \n \n \n \n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '' : ''}\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 \n \n \n \n \n \n `;\n }).join('')}\n \n
NameUsername/EmailRequested OnActions
No pending reset requests
${tech.name || 'Unknown'}${r.employee_id || tech.username || ''}${requestedAt}\n
\n \n \n
\n
\n
\n
\n\n ${isLocalMode ? '' : `\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n `}\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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

Approve password reset request for ${t.name}? Please enter their new password below:

\n
\n \n \n
\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n
\n

Tax Rates

\n
\n
\n \n
\n %\n
\n
\n
\n
\n\n
\n

Job Types

\n
\n

Define the types of jobs your team performs (e.g. Electrical, Plumbing). These appear in job forms and rate mapping.

\n
\n ${currentJobTypes.map(type => `\n \n ${escapeHTML(type)}\n close\n \n `).join('')}\n
\n
\n \n \n
\n
\n
\n
\n\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => {\n const active = applicable.includes(day);\n return `\n \n `}).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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 \n ${escapeHTML(type)}\n close\n \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 = 'sync 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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n

Contractor Portal Settings

\n
\n
\n \n
\n Enable Contractor Portal Link Access\n When disabled, any subcontractor attempting to load their portal token will see an access deactivated notice.\n
\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n \n
\n \n
\n

Data Management

\n
\n

\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n

\n
\n ${currentUser.role === 'admin' ? `\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n

\n \n \n \n\n ${isLocalMode ? `\n
\n
\n warning Irreversible Profile Conversions\n
\n

\n Convert your deployment type. These transitions are permanent and one-way.\n

\n ${localDeploymentType === 'single_user' ? `\n \n ` : ''}\n \n
\n ` : ''}\n
\n ` : '
Administrative actions are restricted to company administrators.
'}\n
\n
\n
\n\n \n ${!isLocalMode && currentUser.role === 'admin' ? `\n
\n
\n backup\n

Local Data Backup

\n
\n
\n

\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n

\n
\n
\n
\n ` : ''}\n
\n\n \n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n \n \n

\n Select a color profile and visual background effect for your user interface.\n

\n
\n
\n
\n
\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

You are about to seed a complete demonstration dataset.

\n
\n info\n 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.\n
\n

This is highly recommended for testing and walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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

WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)

\n
\n warning\n You are about to permanently delete the entire company profile for ${escapeHTML(store.getSettings().name || '')} and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).\n
\n

\n This action is final, irreversible, and cannot be undone under any circumstances.\n

\n

Do you want to proceed to password confirmation?

\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
\n

FINAL CONFIRMATION (2 of 2)

\n
\n gavel\n To authorize the permanent destruction of this company, you must enter the Administrator password.\n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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 = 'sync 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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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
\n Browser Local Folder Access Unsupported\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n
\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n
\n folder_off\n
No backup folder configured.
\n
\n \n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n
\n Permission Required
\n Access permission to ${escapeHTML(store.backupDirHandle.name)} has expired. Please re-authorize.\n
\n
\n \n \n
\n `;\n } else {\n bsc.innerHTML = `\n
\n Backup Configured
\n Folder: ${escapeHTML(store.backupDirHandle.name)}
\n Last Backup: ${formattedLastBackup}\n
\n
\n \n
\n \n \n
\n
\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 = 'sync 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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n ${!editId ? `\n
\n Company login code is: ${companySlug}. User will log in with username@${companySlug}\n
\n ` : ''}\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 = 'sync 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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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
\n

Online Payments (Stripe)

\n
\n

Online card payments are a cloud feature. Upgrade to a cloud account to let customers pay invoices online by card.

\n
\n
`;\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
\n

Online Payments (Stripe)

\n
\n

\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

\n\n
\n
One-time setup
\n
    \n
  1. Create a Stripe account and copy your Secret key.
  2. \n
  3. In Supabase → Edge Functions → Secrets, add STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET.
  4. \n
  5. Deploy the relay-create-payment and relay-stripe-webhook functions.
  6. \n
  7. In Stripe → Developers → Webhooks, add an endpoint pointing at relay-stripe-webhook and subscribe it to checkout.session.completed.
  8. \n
\n
\n\n
\n \n
\n
Payments configured & live
\n
Tick once the setup above is done. Controls whether the Pay Link button appears on invoices.
\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n
\n
\n
`;\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 = 'sync 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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n
\n error_outline\n Browser Directory Access Unsupported\n
\n

\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

\n
\n `;\n } else if (!isEnabled) {\n statusHtml = `\n
\n folder_off\n
\n
Folder Synchronization Inactive
\n

All data is currently stored in your browser's private IndexedDB database sandbox.

\n
\n
\n `;\n } else if (isCapacitor) {\n statusHtml = `\n
\n cloud_done\n
\n
Capacitor Direct Folder Sync Active
\n

\n Data is synchronizing directly to the application's native Documents/RelayDispatchData directory on your iPad/device.\n

\n
\n
\n `;\n } else if (!hasHandle) {\n statusHtml = `\n
\n warning\n
\n
No Directory Selected
\n

Please select a directory folder on your computer to begin syncing.

\n
\n
\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n
\n
\n lock\n
\n
Access Permission Suspended
\n

\n The browser requires re-authorization to read/write files in ${escapeHTML(store.dirHandle.name)}.\n

\n
\n
\n \n
\n `;\n } else {\n statusHtml = `\n
\n check_circle\n
\n
Folder Sync Active & Synchronized
\n

\n Active Folder: ${escapeHTML(store.dirHandle.name)}. All data edits are writing dynamically.\n

\n
\n
\n `;\n }\n\n tc.innerHTML = `\n
\n \n
\n

Direct Directory Sync

\n
\n ${statusHtml}\n\n
\n \n \n
\n\n ${isEnabled && isSupported && !isCapacitor ? `\n
\n \n ${hasHandle && isPermissionGranted ? `\n \n ${!isLocalCompany ? `\n \n ` : ''}\n ` : ''}\n
\n ` : ''}\n
\n
\n\n \n
\n

How Self-Hosting Works

\n
\n

\n By linking a local folder, you establish a serverless self-hosted data hub.\n

\n
\n storage\n Readable Data: Your jobs, quotes, and company configurations are saved in data/*.json files.\n
\n
\n photo_library\n Physical Attachments: Uploaded photos and PDF catalogs are saved as standard image/PDF files under the documents/ subfolder.\n
\n
\n sync_alt\n Cloud Backups: Select a folder synced to OneDrive, Google Drive, or Dropbox to automatically back up and sync your data to the cloud.\n
\n
\n
\n
\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
\n
\n
\n

AI Assistant Configuration

\n
\n \n
\n \n
\n\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n \n
\n
\n
${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.'}
\n\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \n \n
Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.
\n
\n\n
\n \n \n
\n
\n
\n\n
\n \n
\n\n
\n
\n\n
\n

Google Maps Configuration

\n
\n
\n \n \n
Used for address autocomplete, distance calculations, and rendering maps. Get this from the Google Cloud Console.
\n
\n
\n \n
\n
\n
\n\n
\n\n \n
\n

Cheapest Smart AI Guide

\n
\n

\n DeepSeek is one of the most cost-efficient and high-performance language models currently available, making it perfect for lightweight co-pilot tasks.\n

\n
\n key\n API Key: Get your key from platform.deepseek.com. New accounts typically receive free trial credits.\n
\n
\n security\n CORS Restrictions: Due to browser security, direct API calls to DeepSeek may be blocked. To bypass this:\n
    \n
  • Route via a CORS proxy (e.g. https://cors-anywhere.herokuapp.com/ prefix).
  • \n
  • Connect via an aggregator like OpenRouter (API Endpoint: https://openrouter.ai/api/v1/chat/completions).
  • \n
  • Use a local LLM runner like Ollama (API Endpoint: http://localhost:11434/v1/chat/completions).
  • \n
\n
\n
\n
\n
\n\n
\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
\n
\n

Cost Centers

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n ${list.length === 0 ? `\n \n \n \n ` : list.map(cc => `\n \n \n \n \n \n \n \n `).join('')}\n \n
CodeNameXero MappingStatusActions
\n No Cost Centers configured. Click \"Add Cost Center\" to create one.\n
${escapeHTML(cc.code)}${escapeHTML(cc.name)}\n ${cc.xeroSalesAccountCode || cc.xeroTrackingOptionName ? `\n
\n ${cc.xeroSalesAccountCode ? `
Sales Code: ${escapeHTML(cc.xeroSalesAccountCode)}
` : ''}\n ${cc.xeroExpenseAccountCode ? `
Expense Code: ${escapeHTML(cc.xeroExpenseAccountCode)}
` : ''}\n ${cc.xeroTrackingOptionName ? `
Tracking: ${escapeHTML(cc.xeroTrackingCategoryName || 'Department')}:${escapeHTML(cc.xeroTrackingOptionName)}
` : ''}\n
\n ` : '— Unmapped —'}\n
\n \n ${cc.active ? 'Active' : 'Inactive'}\n \n \n \n \n
\n
\n
\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
\n \n \n
\n
\n \n \n
\n \n
\n \n sync Xero Integration (Optional)\n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n\n
\n \n
\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
\n
\n

Supplier Categories

\n
\n

Define classifications/categories for your suppliers (e.g. Electrical, Plumbing, HVAC). These categories are used to group suppliers in the Suppliers directory.

\n
\n ${categories.map(c => `\n
\n ${escapeHTML(c)}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 close\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 = 'sync 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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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 \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 \n `;\n }).join('');\n\n container.innerHTML = `\n \n\n

Settings

\n\n
\n ${categoryTabsHtml}\n
\n\n
\n ${subTabsHtml}\n
\n\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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 = 'sync 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
\n \n info\n Your company login code is ${companySlug}. Technicians log in using username@${companySlug}.\n \n
\n\n
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeUsernamePay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n ${ut?.id !== 'ut_admin' ? `\n \n ` : ''}\n
\n
\n
\n
\n\n
\n
\n

Pending Password Reset Requests

\n
\n
\n \n \n \n \n \n \n \n \n \n \n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '' : ''}\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 \n \n \n \n \n \n `;\n }).join('')}\n \n
NameUsername/EmailRequested OnActions
No pending reset requests
${tech.name || 'Unknown'}${r.employee_id || tech.username || ''}${requestedAt}\n
\n \n \n
\n
\n
\n
\n\n ${isLocalMode ? '' : `\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n `}\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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

Approve password reset request for ${t.name}? Please enter their new password below:

\n
\n \n \n
\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n
\n

Tax Rates

\n
\n
\n \n
\n %\n
\n
\n
\n
\n\n
\n

Job Types

\n
\n

Define the types of jobs your team performs (e.g. Electrical, Plumbing). These appear in job forms and rate mapping.

\n
\n ${currentJobTypes.map(type => `\n \n ${escapeHTML(type)}\n close\n \n `).join('')}\n
\n
\n \n \n
\n
\n
\n
\n\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => {\n const active = applicable.includes(day);\n return `\n \n `}).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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 \n ${escapeHTML(type)}\n close\n \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 = 'sync 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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n

Contractor Portal Settings

\n
\n
\n \n
\n Enable Contractor Portal Link Access\n When disabled, any subcontractor attempting to load their portal token will see an access deactivated notice.\n
\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n \n
\n \n
\n

Data Management

\n
\n

\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n

\n
\n ${currentUser.role === 'admin' ? `\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n

\n \n \n \n\n ${isLocalMode ? `\n
\n
\n warning Irreversible Profile Conversions\n
\n

\n Convert your deployment type. These transitions are permanent and one-way.\n

\n ${localDeploymentType === 'single_user' ? `\n \n ` : ''}\n \n
\n ` : ''}\n
\n ` : '
Administrative actions are restricted to company administrators.
'}\n
\n
\n
\n\n \n ${!isLocalMode && currentUser.role === 'admin' ? `\n
\n
\n backup\n

Local Data Backup

\n
\n
\n

\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n

\n
\n
\n
\n ` : ''}\n
\n\n \n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n \n \n

\n Select a color profile and visual background effect for your user interface.\n

\n
\n
\n
\n
\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

You are about to seed a complete demonstration dataset.

\n
\n info\n 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.\n
\n

This is highly recommended for testing and walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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

WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)

\n
\n warning\n You are about to permanently delete the entire company profile for ${escapeHTML(store.getSettings().name || '')} and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).\n
\n

\n This action is final, irreversible, and cannot be undone under any circumstances.\n

\n

Do you want to proceed to password confirmation?

\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
\n

FINAL CONFIRMATION (2 of 2)

\n
\n gavel\n To authorize the permanent destruction of this company, you must enter the Administrator password.\n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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 = 'sync 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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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
\n Browser Local Folder Access Unsupported\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n
\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n
\n folder_off\n
No backup folder configured.
\n
\n \n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n
\n Permission Required
\n Access permission to ${escapeHTML(store.backupDirHandle.name)} has expired. Please re-authorize.\n
\n
\n \n \n
\n `;\n } else {\n bsc.innerHTML = `\n
\n Backup Configured
\n Folder: ${escapeHTML(store.backupDirHandle.name)}
\n Last Backup: ${formattedLastBackup}\n
\n
\n \n
\n \n \n
\n
\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 = 'sync 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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n ${!editId ? `\n
\n Company login code is: ${companySlug}. User will log in with username@${companySlug}\n
\n ` : ''}\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 = 'sync 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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 = 'sync 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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n
\n error_outline\n Browser Directory Access Unsupported\n
\n

\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

\n
\n `;\n } else if (!isEnabled) {\n statusHtml = `\n
\n folder_off\n
\n
Folder Synchronization Inactive
\n

All data is currently stored in your browser's private IndexedDB database sandbox.

\n
\n
\n `;\n } else if (isCapacitor) {\n statusHtml = `\n
\n cloud_done\n
\n
Capacitor Direct Folder Sync Active
\n

\n Data is synchronizing directly to the application's native Documents/RelayDispatchData directory on your iPad/device.\n

\n
\n
\n `;\n } else if (!hasHandle) {\n statusHtml = `\n
\n warning\n
\n
No Directory Selected
\n

Please select a directory folder on your computer to begin syncing.

\n
\n
\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n
\n
\n lock\n
\n
Access Permission Suspended
\n

\n The browser requires re-authorization to read/write files in ${escapeHTML(store.dirHandle.name)}.\n

\n
\n
\n \n
\n `;\n } else {\n statusHtml = `\n
\n check_circle\n
\n
Folder Sync Active & Synchronized
\n

\n Active Folder: ${escapeHTML(store.dirHandle.name)}. All data edits are writing dynamically.\n

\n
\n
\n `;\n }\n\n tc.innerHTML = `\n
\n \n
\n

Direct Directory Sync

\n
\n ${statusHtml}\n\n
\n \n \n
\n\n ${isEnabled && isSupported && !isCapacitor ? `\n
\n \n ${hasHandle && isPermissionGranted ? `\n \n ${!isLocalCompany ? `\n \n ` : ''}\n ` : ''}\n
\n ` : ''}\n
\n
\n\n \n
\n

How Self-Hosting Works

\n
\n

\n By linking a local folder, you establish a serverless self-hosted data hub.\n

\n
\n storage\n Readable Data: Your jobs, quotes, and company configurations are saved in data/*.json files.\n
\n
\n photo_library\n Physical Attachments: Uploaded photos and PDF catalogs are saved as standard image/PDF files under the documents/ subfolder.\n
\n
\n sync_alt\n Cloud Backups: Select a folder synced to OneDrive, Google Drive, or Dropbox to automatically back up and sync your data to the cloud.\n
\n
\n
\n
\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
\n
\n
\n

AI Assistant Configuration

\n
\n \n
\n \n
\n\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n \n
\n
\n
${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.'}
\n\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \n \n
Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.
\n
\n\n
\n \n \n
\n
\n
\n\n
\n \n
\n\n
\n
\n\n
\n

Google Maps Configuration

\n
\n
\n \n \n
Used for address autocomplete, distance calculations, and rendering maps. Get this from the Google Cloud Console.
\n
\n
\n \n
\n
\n
\n\n
\n\n \n
\n

Cheapest Smart AI Guide

\n
\n

\n DeepSeek is one of the most cost-efficient and high-performance language models currently available, making it perfect for lightweight co-pilot tasks.\n

\n
\n key\n API Key: Get your key from platform.deepseek.com. New accounts typically receive free trial credits.\n
\n
\n security\n CORS Restrictions: Due to browser security, direct API calls to DeepSeek may be blocked. To bypass this:\n
    \n
  • Route via a CORS proxy (e.g. https://cors-anywhere.herokuapp.com/ prefix).
  • \n
  • Connect via an aggregator like OpenRouter (API Endpoint: https://openrouter.ai/api/v1/chat/completions).
  • \n
  • Use a local LLM runner like Ollama (API Endpoint: http://localhost:11434/v1/chat/completions).
  • \n
\n
\n
\n
\n
\n\n
\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
\n
\n

Cost Centers

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n ${list.length === 0 ? `\n \n \n \n ` : list.map(cc => `\n \n \n \n \n \n \n \n `).join('')}\n \n
CodeNameXero MappingStatusActions
\n No Cost Centers configured. Click \"Add Cost Center\" to create one.\n
${escapeHTML(cc.code)}${escapeHTML(cc.name)}\n ${cc.xeroSalesAccountCode || cc.xeroTrackingOptionName ? `\n
\n ${cc.xeroSalesAccountCode ? `
Sales Code: ${escapeHTML(cc.xeroSalesAccountCode)}
` : ''}\n ${cc.xeroExpenseAccountCode ? `
Expense Code: ${escapeHTML(cc.xeroExpenseAccountCode)}
` : ''}\n ${cc.xeroTrackingOptionName ? `
Tracking: ${escapeHTML(cc.xeroTrackingCategoryName || 'Department')}:${escapeHTML(cc.xeroTrackingOptionName)}
` : ''}\n
\n ` : '— Unmapped —'}\n
\n \n ${cc.active ? 'Active' : 'Inactive'}\n \n \n \n \n
\n
\n
\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
\n \n \n
\n
\n \n \n
\n \n
\n \n sync Xero Integration (Optional)\n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n\n
\n \n
\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
\n
\n

Supplier Categories

\n
\n

Define classifications/categories for your suppliers (e.g. Electrical, Plumbing, HVAC). These categories are used to group suppliers in the Suppliers directory.

\n
\n ${categories.map(c => `\n
\n ${escapeHTML(c)}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 close\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 = 'sync 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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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 \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 \n `;\n }).join('');\n\n container.innerHTML = `\n \n\n

Settings

\n\n
\n ${categoryTabsHtml}\n
\n\n
\n ${subTabsHtml}\n
\n\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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 = 'sync 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
\n \n info\n Your company login code is ${companySlug}. Technicians log in using username@${companySlug}.\n \n
\n\n
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeUsernamePay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n ${ut?.id !== 'ut_admin' ? `\n \n ` : ''}\n
\n
\n
\n
\n\n
\n
\n

Pending Password Reset Requests

\n
\n
\n \n \n \n \n \n \n \n \n \n \n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '' : ''}\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 \n \n \n \n \n \n `;\n }).join('')}\n \n
NameUsername/EmailRequested OnActions
No pending reset requests
${tech.name || 'Unknown'}${r.employee_id || tech.username || ''}${requestedAt}\n
\n \n \n
\n
\n
\n
\n\n ${isLocalMode ? '' : `\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n `}\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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

Approve password reset request for ${t.name}? Please enter their new password below:

\n
\n \n \n
\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n
\n

Tax Rates

\n
\n
\n \n
\n %\n
\n
\n
\n
\n\n
\n

Job Types

\n
\n

Define the types of jobs your team performs (e.g. Electrical, Plumbing). These appear in job forms and rate mapping.

\n
\n ${currentJobTypes.map(type => `\n \n ${escapeHTML(type)}\n close\n \n `).join('')}\n
\n
\n \n \n
\n
\n
\n
\n\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => {\n const active = applicable.includes(day);\n return `\n \n `}).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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 \n ${escapeHTML(type)}\n close\n \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 = 'sync 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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n

Contractor Portal Settings

\n
\n
\n \n
\n Enable Contractor Portal Link Access\n When disabled, any subcontractor attempting to load their portal token will see an access deactivated notice.\n
\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n \n
\n \n
\n

Data Management

\n
\n

\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n

\n
\n ${currentUser.role === 'admin' ? `\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n

\n \n \n \n\n ${isLocalMode ? `\n
\n
\n warning Irreversible Profile Conversions\n
\n

\n Convert your deployment type. These transitions are permanent and one-way.\n

\n ${localDeploymentType === 'single_user' ? `\n \n ` : ''}\n \n
\n ` : ''}\n
\n ` : '
Administrative actions are restricted to company administrators.
'}\n
\n
\n
\n\n \n ${!isLocalMode && currentUser.role === 'admin' ? `\n
\n
\n backup\n

Local Data Backup

\n
\n
\n

\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n

\n
\n
\n
\n ` : ''}\n
\n\n \n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n \n \n

\n Select a color profile and visual background effect for your user interface.\n

\n
\n
\n
\n
\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

You are about to seed a complete demonstration dataset.

\n
\n info\n 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.\n
\n

This is highly recommended for testing and walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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

WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)

\n
\n warning\n You are about to permanently delete the entire company profile for ${escapeHTML(store.getSettings().name || '')} and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).\n
\n

\n This action is final, irreversible, and cannot be undone under any circumstances.\n

\n

Do you want to proceed to password confirmation?

\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
\n

FINAL CONFIRMATION (2 of 2)

\n
\n gavel\n To authorize the permanent destruction of this company, you must enter the Administrator password.\n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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 = 'sync 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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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
\n Browser Local Folder Access Unsupported\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n
\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n
\n folder_off\n
No backup folder configured.
\n
\n \n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n
\n Permission Required
\n Access permission to ${escapeHTML(store.backupDirHandle.name)} has expired. Please re-authorize.\n
\n
\n \n \n
\n `;\n } else {\n bsc.innerHTML = `\n
\n Backup Configured
\n Folder: ${escapeHTML(store.backupDirHandle.name)}
\n Last Backup: ${formattedLastBackup}\n
\n
\n \n
\n \n \n
\n
\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 = 'sync 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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n ${!editId ? `\n
\n Company login code is: ${companySlug}. User will log in with username@${companySlug}\n
\n ` : ''}\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 = 'sync 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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 = 'sync 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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n
\n error_outline\n Browser Directory Access Unsupported\n
\n

\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

\n
\n `;\n } else if (!isEnabled) {\n statusHtml = `\n
\n folder_off\n
\n
Folder Synchronization Inactive
\n

All data is currently stored in your browser's private IndexedDB database sandbox.

\n
\n
\n `;\n } else if (isCapacitor) {\n statusHtml = `\n
\n cloud_done\n
\n
Capacitor Direct Folder Sync Active
\n

\n Data is synchronizing directly to the application's native Documents/RelayDispatchData directory on your iPad/device.\n

\n
\n
\n `;\n } else if (!hasHandle) {\n statusHtml = `\n
\n warning\n
\n
No Directory Selected
\n

Please select a directory folder on your computer to begin syncing.

\n
\n
\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n
\n
\n lock\n
\n
Access Permission Suspended
\n

\n The browser requires re-authorization to read/write files in ${escapeHTML(store.dirHandle.name)}.\n

\n
\n
\n \n
\n `;\n } else {\n statusHtml = `\n
\n check_circle\n
\n
Folder Sync Active & Synchronized
\n

\n Active Folder: ${escapeHTML(store.dirHandle.name)}. All data edits are writing dynamically.\n

\n
\n
\n `;\n }\n\n tc.innerHTML = `\n
\n \n
\n

Direct Directory Sync

\n
\n ${statusHtml}\n\n
\n \n \n
\n\n ${isEnabled && isSupported && !isCapacitor ? `\n
\n \n ${hasHandle && isPermissionGranted ? `\n \n ${!isLocalCompany ? `\n \n ` : ''}\n ` : ''}\n
\n ` : ''}\n
\n
\n\n \n
\n

How Self-Hosting Works

\n
\n

\n By linking a local folder, you establish a serverless self-hosted data hub.\n

\n
\n storage\n Readable Data: Your jobs, quotes, and company configurations are saved in data/*.json files.\n
\n
\n photo_library\n Physical Attachments: Uploaded photos and PDF catalogs are saved as standard image/PDF files under the documents/ subfolder.\n
\n
\n sync_alt\n Cloud Backups: Select a folder synced to OneDrive, Google Drive, or Dropbox to automatically back up and sync your data to the cloud.\n
\n
\n
\n
\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
\n
\n
\n

AI Assistant Configuration

\n
\n \n
\n \n
\n\n
\n
\n \n \n
${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.'}
\n
\n\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \n \n
Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.
\n
\n\n
\n \n \n
\n
\n
\n\n
\n \n
\n\n
\n
\n\n\n\n
\n\n \n
\n

Cheapest Smart AI Guide

\n
\n

\n DeepSeek is one of the most cost-efficient and high-performance language models currently available, making it perfect for lightweight co-pilot tasks.\n

\n
\n key\n API Key: Get your key from platform.deepseek.com. New accounts typically receive free trial credits.\n
\n
\n security\n CORS Restrictions: Due to browser security, direct API calls to DeepSeek may be blocked. To bypass this:\n
    \n
  • Route via a CORS proxy (e.g. https://cors-anywhere.herokuapp.com/ prefix).
  • \n
  • Connect via an aggregator like OpenRouter (API Endpoint: https://openrouter.ai/api/v1/chat/completions).
  • \n
  • Use a local LLM runner like Ollama (API Endpoint: http://localhost:11434/v1/chat/completions).
  • \n
\n
\n
\n
\n
\n\n
\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
\n
\n

Cost Centers

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n ${list.length === 0 ? `\n \n \n \n ` : list.map(cc => `\n \n \n \n \n \n \n \n `).join('')}\n \n
CodeNameXero MappingStatusActions
\n No Cost Centers configured. Click \"Add Cost Center\" to create one.\n
${escapeHTML(cc.code)}${escapeHTML(cc.name)}\n ${cc.xeroSalesAccountCode || cc.xeroTrackingOptionName ? `\n
\n ${cc.xeroSalesAccountCode ? `
Sales Code: ${escapeHTML(cc.xeroSalesAccountCode)}
` : ''}\n ${cc.xeroExpenseAccountCode ? `
Expense Code: ${escapeHTML(cc.xeroExpenseAccountCode)}
` : ''}\n ${cc.xeroTrackingOptionName ? `
Tracking: ${escapeHTML(cc.xeroTrackingCategoryName || 'Department')}:${escapeHTML(cc.xeroTrackingOptionName)}
` : ''}\n
\n ` : '— Unmapped —'}\n
\n \n ${cc.active ? 'Active' : 'Inactive'}\n \n \n \n \n
\n
\n
\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
\n \n \n
\n
\n \n \n
\n \n
\n \n sync Xero Integration (Optional)\n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n\n
\n \n
\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
\n
\n

Supplier Categories

\n
\n

Define classifications/categories for your suppliers (e.g. Electrical, Plumbing, HVAC). These categories are used to group suppliers in the Suppliers directory.

\n
\n ${categories.map(c => `\n
\n ${escapeHTML(c)}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 close\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 = 'sync 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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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 = 'sync 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
\n \n info\n Your company login code is ${companySlug}. Technicians log in using username@${companySlug}.\n \n
\n\n
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeUsernamePay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n ${ut?.id !== 'ut_admin' ? `\n \n ` : ''}\n
\n
\n
\n
\n\n
\n
\n

Pending Password Reset Requests

\n
\n
\n \n \n \n \n \n \n \n \n \n \n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '' : ''}\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 \n \n \n \n \n \n `;\n }).join('')}\n \n
NameUsername/EmailRequested OnActions
No pending reset requests
${tech.name || 'Unknown'}${r.employee_id || tech.username || ''}${requestedAt}\n
\n \n \n
\n
\n
\n
\n\n ${isLocalMode ? '' : `\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n `}\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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

Approve password reset request for ${t.name}? Please enter their new password below:

\n
\n \n \n
\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n
\n

Tax Rates

\n
\n
\n \n
\n %\n
\n
\n
\n
\n\n
\n

Job Types

\n
\n

Define the types of jobs your team performs (e.g. Electrical, Plumbing). These appear in job forms and rate mapping.

\n
\n ${currentJobTypes.map(type => `\n \n ${escapeHTML(type)}\n close\n \n `).join('')}\n
\n
\n \n \n
\n
\n
\n
\n\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => {\n const active = applicable.includes(day);\n return `\n \n `}).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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 \n ${escapeHTML(type)}\n close\n \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 = 'sync 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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n \n
\n \n
\n

Data Management

\n
\n

\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n

\n
\n ${currentUser.role === 'admin' ? `\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n

\n \n \n \n\n ${isLocalMode ? `\n
\n
\n warning Irreversible Profile Conversions\n
\n

\n Convert your deployment type. These transitions are permanent and one-way.\n

\n ${localDeploymentType === 'single_user' ? `\n \n ` : ''}\n \n
\n ` : ''}\n
\n ` : '
Administrative actions are restricted to company administrators.
'}\n
\n
\n
\n\n \n ${!isLocalMode && currentUser.role === 'admin' ? `\n
\n
\n backup\n

Local Data Backup

\n
\n
\n

\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n

\n
\n
\n
\n ` : ''}\n
\n\n \n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n \n \n

\n Select a color profile and visual background effect for your user interface.\n

\n
\n
\n
\n
\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

You are about to seed a complete demonstration dataset.

\n
\n info\n 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.\n
\n

This is highly recommended for testing and walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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

WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)

\n
\n warning\n You are about to permanently delete the entire company profile for ${escapeHTML(store.getSettings().name || '')} and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).\n
\n

\n This action is final, irreversible, and cannot be undone under any circumstances.\n

\n

Do you want to proceed to password confirmation?

\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
\n

FINAL CONFIRMATION (2 of 2)

\n
\n gavel\n To authorize the permanent destruction of this company, you must enter the Administrator password.\n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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 = 'sync 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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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
\n Browser Local Folder Access Unsupported\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n
\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n
\n folder_off\n
No backup folder configured.
\n
\n \n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n
\n Permission Required
\n Access permission to ${escapeHTML(store.backupDirHandle.name)} has expired. Please re-authorize.\n
\n
\n \n \n
\n `;\n } else {\n bsc.innerHTML = `\n
\n Backup Configured
\n Folder: ${escapeHTML(store.backupDirHandle.name)}
\n Last Backup: ${formattedLastBackup}\n
\n
\n \n
\n \n \n
\n
\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 = 'sync 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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n ${!editId ? `\n
\n Company login code is: ${companySlug}. User will log in with username@${companySlug}\n
\n ` : ''}\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 = 'sync 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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 = 'sync 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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n
\n error_outline\n Browser Directory Access Unsupported\n
\n

\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

\n
\n `;\n } else if (!isEnabled) {\n statusHtml = `\n
\n folder_off\n
\n
Folder Synchronization Inactive
\n

All data is currently stored in your browser's private IndexedDB database sandbox.

\n
\n
\n `;\n } else if (isCapacitor) {\n statusHtml = `\n
\n cloud_done\n
\n
Capacitor Direct Folder Sync Active
\n

\n Data is synchronizing directly to the application's native Documents/RelayDispatchData directory on your iPad/device.\n

\n
\n
\n `;\n } else if (!hasHandle) {\n statusHtml = `\n
\n warning\n
\n
No Directory Selected
\n

Please select a directory folder on your computer to begin syncing.

\n
\n
\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n
\n
\n lock\n
\n
Access Permission Suspended
\n

\n The browser requires re-authorization to read/write files in ${escapeHTML(store.dirHandle.name)}.\n

\n
\n
\n \n
\n `;\n } else {\n statusHtml = `\n
\n check_circle\n
\n
Folder Sync Active & Synchronized
\n

\n Active Folder: ${escapeHTML(store.dirHandle.name)}. All data edits are writing dynamically.\n

\n
\n
\n `;\n }\n\n tc.innerHTML = `\n
\n \n
\n

Direct Directory Sync

\n
\n ${statusHtml}\n\n
\n \n \n
\n\n ${isEnabled && isSupported && !isCapacitor ? `\n
\n \n ${hasHandle && isPermissionGranted ? `\n \n ${!isLocalCompany ? `\n \n ` : ''}\n ` : ''}\n
\n ` : ''}\n
\n
\n\n \n
\n

How Self-Hosting Works

\n
\n

\n By linking a local folder, you establish a serverless self-hosted data hub.\n

\n
\n storage\n Readable Data: Your jobs, quotes, and company configurations are saved in data/*.json files.\n
\n
\n photo_library\n Physical Attachments: Uploaded photos and PDF catalogs are saved as standard image/PDF files under the documents/ subfolder.\n
\n
\n sync_alt\n Cloud Backups: Select a folder synced to OneDrive, Google Drive, or Dropbox to automatically back up and sync your data to the cloud.\n
\n
\n
\n
\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
\n
\n
\n

AI Assistant Configuration

\n
\n \n
\n \n
\n\n
\n
\n \n \n
${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.'}
\n
\n\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \n \n
Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.
\n
\n\n
\n \n \n
\n
\n
\n\n
\n \n
\n\n
\n
\n\n \n
\n

Your Personal Co-Pilot Factsheet (User Memory)

\n
\n
\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
\n \n
\n \n
\n Allows Relay to automatically learn your preferences, default assignees, and work patterns during chats to customize the co-pilot around your day.\n
\n
\n\n
\n \n \n
\n \n
\n \n
\n
\n
\n\n
\n\n \n
\n

Cheapest Smart AI Guide

\n
\n

\n DeepSeek is one of the most cost-efficient and high-performance language models currently available, making it perfect for lightweight co-pilot tasks.\n

\n
\n key\n API Key: Get your key from platform.deepseek.com. New accounts typically receive free trial credits.\n
\n
\n security\n CORS Restrictions: Due to browser security, direct API calls to DeepSeek may be blocked. To bypass this:\n
    \n
  • Route via a CORS proxy (e.g. https://cors-anywhere.herokuapp.com/ prefix).
  • \n
  • Connect via an aggregator like OpenRouter (API Endpoint: https://openrouter.ai/api/v1/chat/completions).
  • \n
  • Use a local LLM runner like Ollama (API Endpoint: http://localhost:11434/v1/chat/completions).
  • \n
\n
\n
\n
\n
\n\n
\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
\n
\n

Cost Centers

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${list.length === 0 ? `\n \n \n \n ` : list.map(cc => `\n \n \n \n \n \n \n `).join('')}\n \n
CodeNameStatusActions
\n No Cost Centers configured. Click \"Add Cost Center\" to create one.\n
${escapeHTML(cc.code)}${escapeHTML(cc.name)}\n \n ${cc.active ? 'Active' : 'Inactive'}\n \n \n \n \n
\n
\n
\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
\n \n \n
\n
\n \n \n
\n
\n \n
\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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 = 'sync 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
\n \n info\n Your company login code is ${companySlug}. Technicians log in using username@${companySlug}.\n \n
\n\n
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeUsernamePay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n ${ut?.id !== 'ut_admin' ? `\n \n ` : ''}\n
\n
\n
\n
\n\n
\n
\n

Pending Password Reset Requests

\n
\n
\n \n \n \n \n \n \n \n \n \n \n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '' : ''}\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 \n \n \n \n \n \n `;\n }).join('')}\n \n
NameUsername/EmailRequested OnActions
No pending reset requests
${tech.name || 'Unknown'}${r.employee_id || tech.username || ''}${requestedAt}\n
\n \n \n
\n
\n
\n
\n\n ${isLocalMode ? '' : `\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n `}\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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

Approve password reset request for ${t.name}? Please enter their new password below:

\n
\n \n \n
\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax Rates

\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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 = 'sync 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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n \n
\n \n
\n

Data Management

\n
\n

\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n

\n
\n ${currentUser.role === 'admin' ? `\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n

\n \n \n \n\n ${isLocalMode ? `\n
\n
\n warning Irreversible Profile Conversions\n
\n

\n Convert your deployment type. These transitions are permanent and one-way.\n

\n ${localDeploymentType === 'single_user' ? `\n \n ` : ''}\n \n
\n ` : ''}\n
\n ` : '
Administrative actions are restricted to company administrators.
'}\n
\n
\n
\n\n \n ${!isLocalMode && currentUser.role === 'admin' ? `\n
\n
\n backup\n

Local Data Backup

\n
\n
\n

\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n

\n
\n
\n
\n ` : ''}\n
\n\n \n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n \n \n

\n Select a color profile and visual background effect for your user interface.\n

\n
\n
\n
\n
\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

You are about to seed a complete demonstration dataset.

\n
\n info\n 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.\n
\n

This is highly recommended for testing and walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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

WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)

\n
\n warning\n You are about to permanently delete the entire company profile for ${escapeHTML(store.getSettings().name || '')} and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).\n
\n

\n This action is final, irreversible, and cannot be undone under any circumstances.\n

\n

Do you want to proceed to password confirmation?

\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
\n

FINAL CONFIRMATION (2 of 2)

\n
\n gavel\n To authorize the permanent destruction of this company, you must enter the Administrator password.\n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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 = 'sync 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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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
\n Browser Local Folder Access Unsupported\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n
\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n
\n folder_off\n
No backup folder configured.
\n
\n \n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n
\n Permission Required
\n Access permission to ${escapeHTML(store.backupDirHandle.name)} has expired. Please re-authorize.\n
\n
\n \n \n
\n `;\n } else {\n bsc.innerHTML = `\n
\n Backup Configured
\n Folder: ${escapeHTML(store.backupDirHandle.name)}
\n Last Backup: ${formattedLastBackup}\n
\n
\n \n
\n \n \n
\n
\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 = 'sync 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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n ${!editId ? `\n
\n Company login code is: ${companySlug}. User will log in with username@${companySlug}\n
\n ` : ''}\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 = 'sync 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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 = 'sync 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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n
\n error_outline\n Browser Directory Access Unsupported\n
\n

\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

\n
\n `;\n } else if (!isEnabled) {\n statusHtml = `\n
\n folder_off\n
\n
Folder Synchronization Inactive
\n

All data is currently stored in your browser's private IndexedDB database sandbox.

\n
\n
\n `;\n } else if (isCapacitor) {\n statusHtml = `\n
\n cloud_done\n
\n
Capacitor Direct Folder Sync Active
\n

\n Data is synchronizing directly to the application's native Documents/RelayDispatchData directory on your iPad/device.\n

\n
\n
\n `;\n } else if (!hasHandle) {\n statusHtml = `\n
\n warning\n
\n
No Directory Selected
\n

Please select a directory folder on your computer to begin syncing.

\n
\n
\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n
\n
\n lock\n
\n
Access Permission Suspended
\n

\n The browser requires re-authorization to read/write files in ${escapeHTML(store.dirHandle.name)}.\n

\n
\n
\n \n
\n `;\n } else {\n statusHtml = `\n
\n check_circle\n
\n
Folder Sync Active & Synchronized
\n

\n Active Folder: ${escapeHTML(store.dirHandle.name)}. All data edits are writing dynamically.\n

\n
\n
\n `;\n }\n\n tc.innerHTML = `\n
\n \n
\n

Direct Directory Sync

\n
\n ${statusHtml}\n\n
\n \n \n
\n\n ${isEnabled && isSupported && !isCapacitor ? `\n
\n \n ${hasHandle && isPermissionGranted ? `\n \n ${!isLocalCompany ? `\n \n ` : ''}\n ` : ''}\n
\n ` : ''}\n
\n
\n\n \n
\n

How Self-Hosting Works

\n
\n

\n By linking a local folder, you establish a serverless self-hosted data hub.\n

\n
\n storage\n Readable Data: Your jobs, quotes, and company configurations are saved in data/*.json files.\n
\n
\n photo_library\n Physical Attachments: Uploaded photos and PDF catalogs are saved as standard image/PDF files under the documents/ subfolder.\n
\n
\n sync_alt\n Cloud Backups: Select a folder synced to OneDrive, Google Drive, or Dropbox to automatically back up and sync your data to the cloud.\n
\n
\n
\n
\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
\n
\n
\n

AI Assistant Configuration

\n
\n \n
\n \n
\n\n
\n
\n \n \n
Your API key is stored locally in your browser/sync directory and never sent to any third party other than the configured API endpoint.
\n
\n\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \n \n
Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.
\n
\n\n
\n \n \n
\n
\n
\n\n
\n \n
\n\n
\n
\n\n
\n
\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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 = 'sync 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
\n \n info\n Your company login code is ${companySlug}. Technicians log in using username@${companySlug}.\n \n
\n\n
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeUsernamePay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n ${ut?.id !== 'ut_admin' ? `\n \n ` : ''}\n
\n
\n
\n
\n\n
\n
\n

Pending Password Reset Requests

\n
\n
\n \n \n \n \n \n \n \n \n \n \n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '' : ''}\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 \n \n \n \n \n \n `;\n }).join('')}\n \n
NameUsername/EmailRequested OnActions
No pending reset requests
${tech.name || 'Unknown'}${r.employee_id || tech.username || ''}${requestedAt}\n
\n \n \n
\n
\n
\n
\n\n ${isLocalMode ? '' : `\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n `}\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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

Approve password reset request for ${t.name}? Please enter their new password below:

\n
\n \n \n
\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax Rates

\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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 = 'sync 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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n \n
\n \n
\n

Data Management

\n
\n

\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n

\n
\n ${currentUser.role === 'admin' ? `\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n

\n \n \n \n\n ${isLocalMode ? `\n
\n
\n warning Irreversible Profile Conversions\n
\n

\n Convert your deployment type. These transitions are permanent and one-way.\n

\n ${localDeploymentType === 'single_user' ? `\n \n ` : ''}\n \n
\n ` : ''}\n
\n ` : '
Administrative actions are restricted to company administrators.
'}\n
\n
\n
\n\n \n ${!isLocalMode && currentUser.role === 'admin' ? `\n
\n
\n backup\n

Local Data Backup

\n
\n
\n

\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n

\n
\n
\n
\n ` : ''}\n
\n\n \n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n \n \n

\n Select a color profile and visual background effect for your user interface.\n

\n
\n
\n
\n
\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

You are about to seed a complete demonstration dataset.

\n
\n info\n 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.\n
\n

This is highly recommended for testing and walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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

WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)

\n
\n warning\n You are about to permanently delete the entire company profile for ${escapeHTML(store.getSettings().name || '')} and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).\n
\n

\n This action is final, irreversible, and cannot be undone under any circumstances.\n

\n

Do you want to proceed to password confirmation?

\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
\n

FINAL CONFIRMATION (2 of 2)

\n
\n gavel\n To authorize the permanent destruction of this company, you must enter the Administrator password.\n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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 = 'sync 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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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
\n Browser Local Folder Access Unsupported\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n
\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n
\n folder_off\n
No backup folder configured.
\n
\n \n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n
\n Permission Required
\n Access permission to ${escapeHTML(store.backupDirHandle.name)} has expired. Please re-authorize.\n
\n
\n \n \n
\n `;\n } else {\n bsc.innerHTML = `\n
\n Backup Configured
\n Folder: ${escapeHTML(store.backupDirHandle.name)}
\n Last Backup: ${formattedLastBackup}\n
\n
\n \n
\n \n \n
\n
\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 = 'sync 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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n ${!editId ? `\n
\n Company login code is: ${companySlug}. User will log in with username@${companySlug}\n
\n ` : ''}\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 = 'sync 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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 = 'sync 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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n
\n error_outline\n Browser Directory Access Unsupported\n
\n

\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

\n
\n `;\n } else if (!isEnabled) {\n statusHtml = `\n
\n folder_off\n
\n
Folder Synchronization Inactive
\n

All data is currently stored in your browser's private IndexedDB database sandbox.

\n
\n
\n `;\n } else if (isCapacitor) {\n statusHtml = `\n
\n cloud_done\n
\n
Capacitor Direct Folder Sync Active
\n

\n Data is synchronizing directly to the application's native Documents/RelayDispatchData directory on your iPad/device.\n

\n
\n
\n `;\n } else if (!hasHandle) {\n statusHtml = `\n
\n warning\n
\n
No Directory Selected
\n

Please select a directory folder on your computer to begin syncing.

\n
\n
\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n
\n
\n lock\n
\n
Access Permission Suspended
\n

\n The browser requires re-authorization to read/write files in ${escapeHTML(store.dirHandle.name)}.\n

\n
\n
\n \n
\n `;\n } else {\n statusHtml = `\n
\n check_circle\n
\n
Folder Sync Active & Synchronized
\n

\n Active Folder: ${escapeHTML(store.dirHandle.name)}. All data edits are writing dynamically.\n

\n
\n
\n `;\n }\n\n tc.innerHTML = `\n
\n \n
\n

Direct Directory Sync

\n
\n ${statusHtml}\n\n
\n \n \n
\n\n ${isEnabled && isSupported && !isCapacitor ? `\n
\n \n ${hasHandle && isPermissionGranted ? `\n \n ${!isLocalCompany ? `\n \n ` : ''}\n ` : ''}\n
\n ` : ''}\n
\n
\n\n \n
\n

How Self-Hosting Works

\n
\n

\n By linking a local folder, you establish a serverless self-hosted data hub.\n

\n
\n storage\n Readable Data: Your jobs, quotes, and company configurations are saved in data/*.json files.\n
\n
\n photo_library\n Physical Attachments: Uploaded photos and PDF catalogs are saved as standard image/PDF files under the documents/ subfolder.\n
\n
\n sync_alt\n Cloud Backups: Select a folder synced to OneDrive, Google Drive, or Dropbox to automatically back up and sync your data to the cloud.\n
\n
\n
\n
\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
\n
\n
\n

AI Assistant Configuration

\n
\n \n
\n \n
\n\n
\n
\n \n \n
Your API key is stored locally in your browser/sync directory and never sent to any third party other than the configured API endpoint.
\n
\n\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \n \n
Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.
\n
\n\n
\n \n \n
\n
\n
\n\n
\n \n
\n\n
\n
\n\n
\n

Your Personal Co-Pilot Factsheet (User Memory)

\n
\n
\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
\n
\n \n
\n
\n \n
\n
\n
\n
\n\n \n
\n

Cheapest Smart AI

\n
\n

\n DeepSeek is one of the most cost-efficient and high-performance language models currently available, making it perfect for lightweight co-pilot tasks.\n

\n
\n key\n API Key: Get your key from platform.deepseek.com. New accounts typically receive free trial credits.\n
\n
\n security\n CORS Restrictions: Due to browser security, direct API calls to DeepSeek may be blocked. To bypass this:\n
    \n
  • Route via a CORS proxy (e.g. https://cors-anywhere.herokuapp.com/ prefix).
  • \n
  • Connect via an aggregator like OpenRouter (API Endpoint: https://openrouter.ai/api/v1/chat/completions).
  • \n
  • Use a local LLM runner like Ollama (API Endpoint: http://localhost:11434/v1/chat/completions).
  • \n
\n
\n
\n
\n
\n
\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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 = 'sync 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
\n \n info\n Your company login code is ${companySlug}. Technicians log in using username@${companySlug}.\n \n
\n\n
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeUsernamePay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n ${ut?.id !== 'ut_admin' ? `\n \n ` : ''}\n
\n
\n
\n
\n\n
\n
\n

Pending Password Reset Requests

\n
\n
\n \n \n \n \n \n \n \n \n \n \n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '' : ''}\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 \n \n \n \n \n \n `;\n }).join('')}\n \n
NameUsername/EmailRequested OnActions
No pending reset requests
${tech.name || 'Unknown'}${r.employee_id || tech.username || ''}${requestedAt}\n
\n \n \n
\n
\n
\n
\n\n ${isLocalMode ? '' : `\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n `}\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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

Approve password reset request for ${t.name}? Please enter their new password below:

\n
\n \n \n
\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax Rates

\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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 = 'sync 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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n \n
\n \n
\n

Data Management

\n
\n

\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n

\n
\n ${currentUser.role === 'admin' ? `\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n

\n \n \n \n\n ${isLocalMode ? `\n
\n
\n warning Irreversible Profile Conversions\n
\n

\n Convert your deployment type. These transitions are permanent and one-way.\n

\n ${localDeploymentType === 'single_user' ? `\n \n ` : ''}\n \n
\n ` : ''}\n
\n ` : '
Administrative actions are restricted to company administrators.
'}\n
\n
\n
\n\n \n ${!isLocalMode && currentUser.role === 'admin' ? `\n
\n
\n backup\n

Local Data Backup

\n
\n
\n

\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n

\n
\n
\n
\n ` : ''}\n
\n\n \n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n \n \n

\n Select a color profile and visual background effect for your user interface.\n

\n
\n
\n
\n
\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

You are about to seed a complete demonstration dataset.

\n
\n info\n 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.\n
\n

This is highly recommended for testing and walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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

WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)

\n
\n warning\n You are about to permanently delete the entire company profile for ${escapeHTML(store.getSettings().name || '')} and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).\n
\n

\n This action is final, irreversible, and cannot be undone under any circumstances.\n

\n

Do you want to proceed to password confirmation?

\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
\n

FINAL CONFIRMATION (2 of 2)

\n
\n gavel\n To authorize the permanent destruction of this company, you must enter the Administrator password.\n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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 = 'sync 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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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
\n Browser Local Folder Access Unsupported\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n
\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n
\n folder_off\n
No backup folder configured.
\n
\n \n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n
\n Permission Required
\n Access permission to ${escapeHTML(store.backupDirHandle.name)} has expired. Please re-authorize.\n
\n
\n \n \n
\n `;\n } else {\n bsc.innerHTML = `\n
\n Backup Configured
\n Folder: ${escapeHTML(store.backupDirHandle.name)}
\n Last Backup: ${formattedLastBackup}\n
\n
\n \n
\n \n \n
\n
\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 = 'sync 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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n ${!editId ? `\n
\n Company login code is: ${companySlug}. User will log in with username@${companySlug}\n
\n ` : ''}\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 = 'sync 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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 = 'sync 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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n
\n error_outline\n Browser Directory Access Unsupported\n
\n

\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

\n
\n `;\n } else if (!isEnabled) {\n statusHtml = `\n
\n folder_off\n
\n
Folder Synchronization Inactive
\n

All data is currently stored in your browser's private IndexedDB database sandbox.

\n
\n
\n `;\n } else if (isCapacitor) {\n statusHtml = `\n
\n cloud_done\n
\n
Capacitor Direct Folder Sync Active
\n

\n Data is synchronizing directly to the application's native Documents/RelayDispatchData directory on your iPad/device.\n

\n
\n
\n `;\n } else if (!hasHandle) {\n statusHtml = `\n
\n warning\n
\n
No Directory Selected
\n

Please select a directory folder on your computer to begin syncing.

\n
\n
\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n
\n
\n lock\n
\n
Access Permission Suspended
\n

\n The browser requires re-authorization to read/write files in ${escapeHTML(store.dirHandle.name)}.\n

\n
\n
\n \n
\n `;\n } else {\n statusHtml = `\n
\n check_circle\n
\n
Folder Sync Active & Synchronized
\n

\n Active Folder: ${escapeHTML(store.dirHandle.name)}. All data edits are writing dynamically.\n

\n
\n
\n `;\n }\n\n tc.innerHTML = `\n
\n \n
\n

Direct Directory Sync

\n
\n ${statusHtml}\n\n
\n \n \n
\n\n ${isEnabled && isSupported && !isCapacitor ? `\n
\n \n ${hasHandle && isPermissionGranted ? `\n \n ${!isLocalCompany ? `\n \n ` : ''}\n ` : ''}\n
\n ` : ''}\n
\n
\n\n \n
\n

How Self-Hosting Works

\n
\n

\n By linking a local folder, you establish a serverless self-hosted data hub.\n

\n
\n storage\n Readable Data: Your jobs, quotes, and company configurations are saved in data/*.json files.\n
\n
\n photo_library\n Physical Attachments: Uploaded photos and PDF catalogs are saved as standard image/PDF files under the documents/ subfolder.\n
\n
\n sync_alt\n Cloud Backups: Select a folder synced to OneDrive, Google Drive, or Dropbox to automatically back up and sync your data to the cloud.\n
\n
\n
\n
\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
\n
\n

AI Assistant Configuration

\n
\n \n
\n \n
\n\n
\n
\n \n \n
Your API key is stored locally in your browser/sync directory and never sent to any third party other than the configured API endpoint.
\n
\n\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \n \n
Defines the persona and basic rules for Relay. Active system records (jobs, quotes, technicians) are appended dynamically in the conversation background.
\n
\n\n
\n \n \n
\n
\n
\n\n
\n \n
\n\n
\n
\n\n \n
\n

Cheapest Smart AI

\n
\n

\n DeepSeek is one of the most cost-efficient and high-performance language models currently available, making it perfect for lightweight co-pilot tasks.\n

\n
\n key\n API Key: Get your key from platform.deepseek.com. New accounts typically receive free trial credits.\n
\n
\n security\n CORS Restrictions: Due to browser security, direct API calls to DeepSeek may be blocked. To bypass this:\n
    \n
  • Route via a CORS proxy (e.g. https://cors-anywhere.herokuapp.com/ prefix).
  • \n
  • Connect via an aggregator like OpenRouter (API Endpoint: https://openrouter.ai/api/v1/chat/completions).
  • \n
  • Use a local LLM runner like Ollama (API Endpoint: http://localhost:11434/v1/chat/completions).
  • \n
\n
\n
\n
\n
\n
\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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 = 'sync 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
\n \n info\n Your company login code is ${companySlug}. Technicians log in using username@${companySlug}.\n \n
\n\n
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeUsernamePay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n ${ut?.id !== 'ut_admin' ? `\n \n ` : ''}\n
\n
\n
\n
\n\n
\n
\n

Pending Password Reset Requests

\n
\n
\n \n \n \n \n \n \n \n \n \n \n ${pendingResets.filter(r => r.status === 'pending').length === 0 ? '' : ''}\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 \n \n \n \n \n \n `;\n }).join('')}\n \n
NameUsername/EmailRequested OnActions
No pending reset requests
${tech.name || 'Unknown'}${r.employee_id || tech.username || ''}${requestedAt}\n
\n \n \n
\n
\n
\n
\n\n ${isLocalMode ? '' : `\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n `}\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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

Approve password reset request for ${t.name}? Please enter their new password below:

\n
\n \n \n
\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax Rates

\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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 = 'sync 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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n \n
\n \n
\n

Data Management

\n
\n

\n ${isLocalMode ? 'Manage your application data. All data is stored locally in your browser.' : 'Manage database records for your cloud company account.'}\n

\n
\n ${currentUser.role === 'admin' ? `\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups, seed a single test sample, or convert your company account deployment profile.\n

\n \n \n \n\n ${isLocalMode ? `\n
\n
\n warning Irreversible Profile Conversions\n
\n

\n Convert your deployment type. These transitions are permanent and one-way.\n

\n ${localDeploymentType === 'single_user' ? `\n \n ` : ''}\n \n
\n ` : ''}\n
\n ` : '
Administrative actions are restricted to company administrators.
'}\n
\n
\n
\n\n \n ${!isLocalMode && currentUser.role === 'admin' ? `\n
\n
\n backup\n

Local Data Backup

\n
\n
\n

\n To keep your data completely safe, configure a local directory to back up all your cloud database records as JSON files.\n

\n
\n
\n
\n ` : ''}\n
\n\n \n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n \n \n

\n Select a color profile and visual background effect for your user interface.\n

\n
\n
\n
\n
\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

You are about to seed a complete demonstration dataset.

\n
\n info\n 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.\n
\n

This is highly recommended for testing and walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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

WARNING: CRITICAL DESTRUCTIVE ACTION (1 of 2)

\n
\n warning\n You are about to permanently delete the entire company profile for ${escapeHTML(store.getSettings().name || '')} and all associated database records (jobs, quotes, invoices, people, assets, forms, etc.).\n
\n

\n This action is final, irreversible, and cannot be undone under any circumstances.\n

\n

Do you want to proceed to password confirmation?

\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
\n

FINAL CONFIRMATION (2 of 2)

\n
\n gavel\n To authorize the permanent destruction of this company, you must enter the Administrator password.\n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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 = 'sync 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
\n
\n warning\n
\n CRITICAL WARNING: 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
\n
\n\n
\n info\n
\n Configure the administrator user credentials. This user will have admin access to add other users in the unlocked Users tab.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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
\n Browser Local Folder Access Unsupported\n Your current browser does not support local folder access. Please use Chrome, Edge, or a Chromium-based browser to configure local backups.\n
\n `;\n return;\n }\n\n if (!hasHandle) {\n bsc.innerHTML = `\n
\n folder_off\n
No backup folder configured.
\n
\n \n `;\n } else if (!isPermissionGranted) {\n bsc.innerHTML = `\n
\n Permission Required
\n Access permission to ${escapeHTML(store.backupDirHandle.name)} has expired. Please re-authorize.\n
\n
\n \n \n
\n `;\n } else {\n bsc.innerHTML = `\n
\n Backup Configured
\n Folder: ${escapeHTML(store.backupDirHandle.name)}
\n Last Backup: ${formattedLastBackup}\n
\n
\n \n
\n \n \n
\n
\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 = 'sync 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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n ${!editId ? `\n
\n Company login code is: ${companySlug}. User will log in with username@${companySlug}\n
\n ` : ''}\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 = 'sync 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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 = 'sync 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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n
\n error_outline\n Browser Directory Access Unsupported\n
\n

\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

\n
\n `;\n } else if (!isEnabled) {\n statusHtml = `\n
\n folder_off\n
\n
Folder Synchronization Inactive
\n

All data is currently stored in your browser's private IndexedDB database sandbox.

\n
\n
\n `;\n } else if (isCapacitor) {\n statusHtml = `\n
\n cloud_done\n
\n
Capacitor Direct Folder Sync Active
\n

\n Data is synchronizing directly to the application's native Documents/RelayDispatchData directory on your iPad/device.\n

\n
\n
\n `;\n } else if (!hasHandle) {\n statusHtml = `\n
\n warning\n
\n
No Directory Selected
\n

Please select a directory folder on your computer to begin syncing.

\n
\n
\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n
\n
\n lock\n
\n
Access Permission Suspended
\n

\n The browser requires re-authorization to read/write files in ${escapeHTML(store.dirHandle.name)}.\n

\n
\n
\n \n
\n `;\n } else {\n statusHtml = `\n
\n check_circle\n
\n
Folder Sync Active & Synchronized
\n

\n Active Folder: ${escapeHTML(store.dirHandle.name)}. All data edits are writing dynamically.\n

\n
\n
\n `;\n }\n\n tc.innerHTML = `\n
\n \n
\n

Direct Directory Sync

\n
\n ${statusHtml}\n\n
\n \n \n
\n\n ${isEnabled && isSupported && !isCapacitor ? `\n
\n \n ${hasHandle && isPermissionGranted ? `\n \n ${!isLocalCompany ? `\n \n ` : ''}\n ` : ''}\n
\n ` : ''}\n
\n
\n\n \n
\n

How Self-Hosting Works

\n
\n

\n By linking a local folder, you establish a serverless self-hosted data hub.\n

\n
\n storage\n Readable Data: Your jobs, quotes, and company configurations are saved in data/*.json files.\n
\n
\n photo_library\n Physical Attachments: Uploaded photos and PDF catalogs are saved as standard image/PDF files under the documents/ subfolder.\n
\n
\n sync_alt\n Cloud Backups: Select a folder synced to OneDrive, Google Drive, or Dropbox to automatically back up and sync your data to the cloud.\n
\n
\n
\n
\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n \n
\n
\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
\n
\n info\n
\n Configure your cloud administrator credentials. This username and password will be your new secure login.\n
\n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n \n
\n\n
\n error_outline\n \n
\n\n
\n \n \n
\n
\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
\n
\n cloud_upload\n

Convert to Cloud Account

\n
\n
\n

\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

\n
\n Conversion Process Highlights:\n
    \n
  • Registers a new administrator account in Supabase.
  • \n
  • Safely uploads all local offline data to the cloud databases.
  • \n
  • Wipes namespaced local files and IndexedDB databases from this device.
  • \n
  • Transitions your session seamlessly to the cloud company dashboard.
  • \n
\n
\n
\n
\n \n
\n
\n `;\n }\n\n tc.innerHTML = `\n
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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 = 'sync 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
\n \n info\n Your company login code is ${companySlug}. Technicians log in using username@${companySlug}.\n \n
\n\n
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeUsernamePay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n ${ut?.id !== 'ut_admin' ? `\n \n ` : ''}\n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax Rates

\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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 = 'sync 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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n \n
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n ${currentUser.role === 'admin' ? `\n
\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups or seed a single test sample to explore the blank app layout.\n

\n \n \n
\n ` : ''}\n
\n
\n
\n\n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n \n \n

\n Select a color profile and visual background effect for your user interface.\n

\n
\n
\n
\n
\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

You are about to seed a minimal example dataset.

\n
\n info\n This will clear current database records and load exactly one example of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.\n
\n

This is highly recommended for quick feature walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n ${!editId ? `\n
\n Company login code is: ${companySlug}. User will log in with username@${companySlug}\n
\n ` : ''}\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 = 'sync 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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 = 'sync 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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n
\n error_outline\n Browser Directory Access Unsupported\n
\n

\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

\n
\n `;\n } else if (!isEnabled) {\n statusHtml = `\n
\n folder_off\n
\n
Folder Synchronization Inactive
\n

All data is currently stored in your browser's private IndexedDB database sandbox.

\n
\n
\n `;\n } else if (isCapacitor) {\n statusHtml = `\n
\n cloud_done\n
\n
Capacitor Direct Folder Sync Active
\n

\n Data is synchronizing directly to the application's native Documents/RelayDispatchData directory on your iPad/device.\n

\n
\n
\n `;\n } else if (!hasHandle) {\n statusHtml = `\n
\n warning\n
\n
No Directory Selected
\n

Please select a directory folder on your computer to begin syncing.

\n
\n
\n `;\n } else if (!isPermissionGranted) {\n statusHtml = `\n
\n
\n lock\n
\n
Access Permission Suspended
\n

\n The browser requires re-authorization to read/write files in ${escapeHTML(store.dirHandle.name)}.\n

\n
\n
\n \n
\n `;\n } else {\n statusHtml = `\n
\n check_circle\n
\n
Folder Sync Active & Synchronized
\n

\n Active Folder: ${escapeHTML(store.dirHandle.name)}. All data edits are writing dynamically.\n

\n
\n
\n `;\n }\n\n tc.innerHTML = `\n
\n \n
\n

Direct Directory Sync

\n
\n ${statusHtml}\n\n
\n \n \n
\n\n ${isEnabled && isSupported && !isCapacitor ? `\n
\n \n ${hasHandle && isPermissionGranted ? `\n \n \n ` : ''}\n
\n ` : ''}\n
\n
\n\n \n
\n

How Self-Hosting Works

\n
\n

\n By linking a local folder, you establish a serverless self-hosted data hub.\n

\n
\n storage\n Readable Data: Your jobs, quotes, and company configurations are saved in data/*.json files.\n
\n
\n photo_library\n Physical Attachments: Uploaded photos and PDF catalogs are saved as standard image/PDF files under the documents/ subfolder.\n
\n
\n sync_alt\n Cloud Backups: Select a folder synced to OneDrive, Google Drive, or Dropbox to automatically back up and sync your data to the cloud.\n
\n
\n
\n
\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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 = 'sync 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
\n \n info\n Your company login code is ${companySlug}. Technicians log in using username@${companySlug}.\n \n
\n\n
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeUsernamePay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n ${ut?.id !== 'ut_admin' ? `\n \n ` : ''}\n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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 = 'sync 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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n \n
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n ${currentUser.role === 'admin' ? `\n
\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups or seed a single test sample to explore the blank app layout.\n

\n \n \n
\n ` : ''}\n
\n
\n
\n\n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n \n \n

\n Select a color profile and visual background effect for your user interface.\n

\n
\n
\n
\n
\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

You are about to seed a minimal example dataset.

\n
\n info\n This will clear current database records and load exactly one example of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.\n
\n

This is highly recommended for quick feature walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n ${!editId ? `\n
\n Company login code is: ${companySlug}. User will log in with username@${companySlug}\n
\n ` : ''}\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 = 'sync 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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 = 'sync 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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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 = 'sync 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
\n \n info\n Your company login code is ${companySlug}. Technicians log in using username@${companySlug}.\n \n
\n\n
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeUsernamePay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n ${ut?.id !== 'ut_admin' ? `\n \n ` : ''}\n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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 = 'sync 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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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 = 'sync 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
\n \n
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n ${currentUser.role === 'admin' ? `\n
\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups or seed a single test sample to explore the blank app layout.\n

\n \n \n
\n ` : ''}\n
\n
\n
\n\n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n \n \n

\n Select a color profile and visual background effect for your user interface.\n

\n
\n
\n
\n
\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

You are about to seed a minimal example dataset.

\n
\n info\n This will clear current database records and load exactly one example of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.\n
\n

This is highly recommended for quick feature walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n ${!editId ? `\n
\n Company login code is: ${companySlug}. User will log in with username@${companySlug}\n
\n ` : ''}\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 = 'sync 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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 = 'sync 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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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
\n \n info\n Your company login code is ${companySlug}. Technicians log in using username@${companySlug}.\n \n
\n\n
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeUsernamePay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n ${ut?.id !== 'ut_admin' ? `\n \n ` : ''}\n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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
\n \n
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n ${currentUser.role === 'admin' ? `\n
\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups or seed a single test sample to explore the blank app layout.\n

\n \n \n
\n ` : ''}\n
\n
\n
\n\n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n \n \n

\n Select a color profile and visual background effect for your user interface.\n

\n
\n
\n
\n
\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

You are about to seed a minimal example dataset.

\n
\n info\n This will clear current database records and load exactly one example of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.\n
\n

This is highly recommended for quick feature walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n ${!editId ? `\n
\n Company login code is: ${companySlug}. User will log in with username@${companySlug}\n
\n ` : ''}\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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
\n \n info\n Your company login code is ${companySlug}. Technicians log in using username@${companySlug}.\n \n
\n\n
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeUsernamePay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n ${ut?.id !== 'ut_admin' ? `\n \n ` : ''}\n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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
\n \n
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n ${currentUser.role === 'admin' ? `\n
\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups or seed a single test sample to explore the blank app layout.\n

\n \n \n
\n ` : ''}\n
\n
\n
\n\n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n \n \n

\n Select a color profile and visual background effect for your user interface.\n

\n
\n
\n
\n
\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

You are about to seed a minimal example dataset.

\n
\n info\n This will clear current database records and load exactly one example of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.\n
\n

This is highly recommended for quick feature walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n ${!editId ? `\n
\n Company login code is: ${companySlug}. User will log in with username@${companySlug}\n
\n ` : ''}\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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
\n \n info\n Your company login code is ${companySlug}. Technicians log in using username@${companySlug}.\n \n
\n\n
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeUsernamePay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n ${ut?.id !== 'ut_admin' ? `\n \n ` : ''}\n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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
\n \n
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n ${currentUser.role === 'admin' ? `\n
\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups or seed a single test sample to explore the blank app layout.\n

\n \n \n
\n ` : ''}\n
\n
\n
\n\n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n
\n
\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

You are about to seed a minimal example dataset.

\n
\n info\n This will clear current database records and load exactly one example of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.\n
\n

This is highly recommended for quick feature walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n ${!editId ? `\n
\n Company login code is: ${companySlug}. User will log in with username@${companySlug}\n
\n ` : ''}\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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
\n \n info\n Your company login code is ${companySlug}. Technicians log in using username@${companySlug}.\n \n
\n\n
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeUsernamePay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.username || (t.email ? t.email.split('@')[0] : '') || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n \n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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
\n \n
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n ${currentUser.role === 'admin' ? `\n
\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups or seed a single test sample to explore the blank app layout.\n

\n \n \n
\n ` : ''}\n
\n
\n
\n\n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n
\n
\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

You are about to seed a minimal example dataset.

\n
\n info\n This will clear current database records and load exactly one example of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.\n
\n

This is highly recommended for quick feature walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n ${!editId ? `\n
\n Company login code is: ${companySlug}. User will log in with username@${companySlug}\n
\n ` : ''}\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeEmailPay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.email || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n \n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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
\n \n
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n ${currentUser.role === 'admin' ? `\n
\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups or seed a single test sample to explore the blank app layout.\n

\n \n \n
\n ` : ''}\n
\n
\n
\n\n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n
\n
\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

You are about to seed a minimal example dataset.

\n
\n info\n This will clear current database records and load exactly one example of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.\n
\n

This is highly recommended for quick feature walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n
\n ${!editId ? `\n
\n \n \n
\n ` : ''}\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeEmailPay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.email || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n \n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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
\n \n
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n ${currentUser.role === 'admin' ? `\n
\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups or seed a single test sample to explore the blank app layout.\n

\n \n \n
\n ` : ''}\n
\n
\n
\n\n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n
\n
\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

You are about to seed a minimal example dataset.

\n
\n info\n This will clear current database records and load exactly one example of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.\n
\n

This is highly recommended for quick feature walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeEmailPay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.email || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n \n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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
\n \n
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n ${currentUser.role === 'admin' ? `\n
\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups or seed a single test sample to explore the blank app layout.\n

\n \n \n
\n ` : ''}\n
\n
\n
\n\n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n
\n
\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

You are about to seed a minimal example dataset.

\n
\n info\n This will clear current database records and load exactly one example of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.\n
\n

This is highly recommended for quick feature walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 \n \n \n \n \n \n \n
\n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n
\n \n \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, '
');\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeEmailPay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.email || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n \n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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
\n \n
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n ${currentUser.role === 'admin' ? `\n
\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups or seed a single test sample to explore the blank app layout.\n

\n \n \n
\n ` : ''}\n
\n
\n
\n\n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n
\n
\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

You are about to seed a minimal example dataset.

\n
\n info\n This will clear current database records and load exactly one example of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.\n
\n

This is highly recommended for quick feature walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 \n \n \n \n \n \n \n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n \n \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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
\n\n \n
\n \n
\n ${pendingLogoSmall ? `` : `\n
\n image\n No small logo\n
\n `}\n
\n \n
\n \n ${pendingLogoSmall ? `` : ''}\n
\n
\n\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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
\n image\n No custom logo\n
\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
\n image\n No small logo\n
\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 = ``;\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 = 'delete';\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 = ``;\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 = 'delete';\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
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeEmailPay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.email || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n \n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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
\n \n
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n ${currentUser.role === 'admin' ? `\n
\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups or seed a single test sample to explore the blank app layout.\n

\n \n \n
\n ` : ''}\n
\n
\n
\n\n \n
\n

Interface Preferences

\n
\n
\n \n \n

\n Controls the descriptive popup helpers shown when hovering over buttons, actions, and categories.\n

\n
\n
\n
\n
\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

You are about to seed a minimal example dataset.

\n
\n info\n This will clear current database records and load exactly one example of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.\n
\n

This is highly recommended for quick feature walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n
\n

Task Details

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n ${!hasSubs ? `\n
\n
\n
\n assignment\n Value Fields\n
\n \n
\n
Define the values a technician needs to record for this task (e.g. pressure readings, temperatures).
\n
\n ${(node.valueFields || []).map((vf, vi) => {\n const ft = vf.fieldType || 'text';\n return `\n
\n
\n drag_indicator\n \n \n \n
\n ${ft === 'number' ? `\n
\n \n
\n Range:\n \n –\n \n
\n
\n ` : ''}\n ${ft === 'text' ? `\n
\n \n
\n ` : ''}\n ${ft === 'dropdown' ? `\n
\n
\n
Options (one per line)
\n \n
\n
\n
Expected / Ideal Value (flags others as out of range)
\n \n
\n
\n ` : ''}\n
`;\n }).join('')}\n ${(!node.valueFields || node.valueFields.length === 0) ? '
No value fields defined. Click \"Add Field\" to create one.
' : ''}\n
\n
\n ` : ''}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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 \n
\n \n \n
\n \n \n
\n

Choose Theme Preset

\n
\n ${Object.entries(presets).map(([key, val]) => {\n const isActive = dt.preset === key;\n return `\n
\n
\n
\n ${val.name}\n
\n ${val.fontFamily}\n
\n `;\n }).join('')}\n
\n
\n palette\n Custom Styling\n
\n Full control\n
\n
\n
\n\n \n
\n

Custom Color & Typography

\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n

Logo Branding & Scaling

\n
\n
\n \n \n
\n
\n
\n \n
\n \n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n\n \n
\n

Custom Copy & Titles

\n
\n
Invoice Options
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n\n
Quote Options
\n
\n \n \n
\n
\n \n \n
\n\n
Global Options
\n
\n \n \n
\n
\n
\n\n \n
\n

Features & Integrations

\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n\n \n
\n \n \n
\n\n
\n\n \n
\n
\n Live Layout Preview\n
\n \n \n
\n
\n
\n \n
\n
\n\n
\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 \n \n \n \n \n \n \n
\n ${generateDocument(activePreviewTab, mockData)}\n
\n \n \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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n\n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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 = ``;\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
\n image\n No custom logo\n
\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
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeEmailPay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.email || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n \n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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
\n
\n

Business Asset Defaults

\n
Recovery Automation Enabled
\n
\n
\n

Configure the default recovery rates for your business equipment. These rates are used to calculate internal job costs when assets are assigned to tasks.

\n \n \n \n \n \n \n \n \n \n \n ${assets.map(a => `\n \n \n \n \n \n `).join('')}\n ${assets.length === 0 ? '' : ''}\n \n
Asset NameCurrent Rate ($/hr)Status
${escapeHTML(a.name)}\n
\n $\n \n
\n
Active
No business assets found. Add assets in the main Assets module.
\n
\n
\n \n
\n
\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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n ${currentUser.role === 'admin' ? `\n
\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups or seed a single test sample to explore the blank app layout.\n

\n \n \n
\n ` : ''}\n
\n
\n
\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

You are about to seed a minimal example dataset.

\n
\n info\n This will clear current database records and load exactly one example of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.\n
\n

This is highly recommended for quick feature walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n ${!isInfoPanelEditing ? `\n
\n

${escapeHTML(node.name)}

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n
Name
\n
${escapeHTML(node.name)}
\n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n
Estimated Hours
\n
${node.estimatedHours || 0} hrs
\n
\n
\n
People
\n
${node.people || 1}
\n
\n
\n `}\n
\n
Description
\n
${escapeHTML(node.description || 'No description provided.')}
\n
\n ` : `\n
\n

Edit Item Details

\n
\n \n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n `}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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 = ``;\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
\n image\n No custom logo\n
\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
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeEmailPay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.email || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n \n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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
\n
\n

Business Asset Defaults

\n
Recovery Automation Enabled
\n
\n
\n

Configure the default recovery rates for your business equipment. These rates are used to calculate internal job costs when assets are assigned to tasks.

\n \n \n \n \n \n \n \n \n \n \n ${assets.map(a => `\n \n \n \n \n \n `).join('')}\n ${assets.length === 0 ? '' : ''}\n \n
Asset NameCurrent Rate ($/hr)Status
${escapeHTML(a.name)}\n
\n $\n \n
\n
Active
No business assets found. Add assets in the main Assets module.
\n
\n
\n \n
\n
\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
\n

Customer Portal Settings

\n
\n
\n \n
\n Enable Customer Portal Link Access\n When disabled, any attempt to visit a customer portal link will show an access restricted notice.\n
\n
\n\n
\n \n \n

This message will appear prominently at the top of the customer's portal dashboard.

\n
\n\n
\n \n \n

These bank details and instructions will be shown to customers when reviewing outstanding invoices in their portal.

\n
\n
\n
\n \n
\n
\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
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n ${currentUser.role === 'admin' ? `\n
\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups or seed a single test sample to explore the blank app layout.\n

\n \n \n
\n ` : ''}\n
\n
\n
\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

You are about to seed a minimal example dataset.

\n
\n info\n This will clear current database records and load exactly one example of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.\n
\n

This is highly recommended for quick feature walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n ${!isInfoPanelEditing ? `\n
\n

${escapeHTML(node.name)}

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n
Name
\n
${escapeHTML(node.name)}
\n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n
Estimated Hours
\n
${node.estimatedHours || 0} hrs
\n
\n
\n
People
\n
${node.people || 1}
\n
\n
\n `}\n
\n
Description
\n
${escapeHTML(node.description || 'No description provided.')}
\n
\n ` : `\n
\n

Edit Item Details

\n
\n \n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n `}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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 = ``;\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
\n image\n No custom logo\n
\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
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeEmailPay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.email || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n \n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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
\n
\n

Business Asset Defaults

\n
Recovery Automation Enabled
\n
\n
\n

Configure the default recovery rates for your business equipment. These rates are used to calculate internal job costs when assets are assigned to tasks.

\n \n \n \n \n \n \n \n \n \n \n ${assets.map(a => `\n \n \n \n \n \n `).join('')}\n ${assets.length === 0 ? '' : ''}\n \n
Asset NameCurrent Rate ($/hr)Status
${escapeHTML(a.name)}\n
\n $\n \n
\n
Active
No business assets found. Add assets in the main Assets module.
\n
\n
\n \n
\n
\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
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n ${currentUser.role === 'admin' ? `\n
\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups or seed a single test sample to explore the blank app layout.\n

\n \n \n
\n ` : ''}\n
\n
\n
\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

You are about to seed a minimal example dataset.

\n
\n info\n This will clear current database records and load exactly one example of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.\n
\n

This is highly recommended for quick feature walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n ${!isInfoPanelEditing ? `\n
\n

${escapeHTML(node.name)}

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n
Name
\n
${escapeHTML(node.name)}
\n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n
Estimated Hours
\n
${node.estimatedHours || 0} hrs
\n
\n
\n
People
\n
${node.people || 1}
\n
\n
\n `}\n
\n
Description
\n
${escapeHTML(node.description || 'No description provided.')}
\n
\n ` : `\n
\n

Edit Item Details

\n
\n \n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n `}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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
\n `;\n }\n\n return `\n
\n \n \n \n
\n ${hours.map(h => `${h}`).join('')}\n
\n \n \n
\n ${blocksHtml}\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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 = ``;\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
\n image\n No custom logo\n
\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
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeEmailPay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.email || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n \n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n \n \n
\n \n
\n $\n \n
\n
\n \n \n ${renderTimelineHtml(rate.activeHours || [])}\n\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n \n \n
\n \n \n
\n \n \n ${renderTimelineHtml(Array.from({length: 18}, (_, i) => i + 16))}\n\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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
\n
\n

Business Asset Defaults

\n
Recovery Automation Enabled
\n
\n
\n

Configure the default recovery rates for your business equipment. These rates are used to calculate internal job costs when assets are assigned to tasks.

\n \n \n \n \n \n \n \n \n \n \n ${assets.map(a => `\n \n \n \n \n \n `).join('')}\n ${assets.length === 0 ? '' : ''}\n \n
Asset NameCurrent Rate ($/hr)Status
${escapeHTML(a.name)}\n
\n $\n \n
\n
Active
No business assets found. Add assets in the main Assets module.
\n
\n
\n \n
\n
\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
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n ${currentUser.role === 'admin' ? `\n
\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups or seed a single test sample to explore the blank app layout.\n

\n \n \n
\n ` : ''}\n
\n
\n
\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

You are about to seed a minimal example dataset.

\n
\n info\n This will clear current database records and load exactly one example of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.\n
\n

This is highly recommended for quick feature walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n ${!isInfoPanelEditing ? `\n
\n

${escapeHTML(node.name)}

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n
Name
\n
${escapeHTML(node.name)}
\n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n
Estimated Hours
\n
${node.estimatedHours || 0} hrs
\n
\n
\n
People
\n
${node.people || 1}
\n
\n
\n `}\n
\n
Description
\n
${escapeHTML(node.description || 'No description provided.')}
\n
\n ` : `\n
\n

Edit Item Details

\n
\n \n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n `}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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 = ``;\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
\n image\n No custom logo\n
\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
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeEmailPay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.email || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n \n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n
\n \n
\n \n × base pay\n
\n
\n \n
\n \n
\n $\n \n
\n
\n \n
\n \n \n
\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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
\n
\n

Business Asset Defaults

\n
Recovery Automation Enabled
\n
\n
\n

Configure the default recovery rates for your business equipment. These rates are used to calculate internal job costs when assets are assigned to tasks.

\n \n \n \n \n \n \n \n \n \n \n ${assets.map(a => `\n \n \n \n \n \n `).join('')}\n ${assets.length === 0 ? '' : ''}\n \n
Asset NameCurrent Rate ($/hr)Status
${escapeHTML(a.name)}\n
\n $\n \n
\n
Active
No business assets found. Add assets in the main Assets module.
\n
\n
\n \n
\n
\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
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n ${currentUser.role === 'admin' ? `\n
\n
\n
\n admin_panel_settings Administrator Actions\n
\n

\n Configure clean setups or seed a single test sample to explore the blank app layout.\n

\n \n \n
\n ` : ''}\n
\n
\n
\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

You are about to seed a minimal example dataset.

\n
\n info\n This will clear current database records and load exactly one example of each business entity (1 customer, 1 lead, 1 quote, 1 job, 1 invoice, etc.) for testing.\n
\n

This is highly recommended for quick feature walkthroughs.

\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

You are about to restore the application to a blank state.

\n
\n warning\n What gets wiped: Customers, Jobs, Tasks, Quotes, Invoices, Purchase Orders, Suppliers, Contractors, Assets, and Schedule Blocks.\n
\n

Only default User Types and Technician login credentials will be retained so you can log back in.

\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

THIS ACTION IS IRREVERSIBLE AND CANNOT BE UNDONE!

\n
\n error_outline\n Confirming will permanently delete all local storage records and start completely fresh from scratch for your real company.\n
\n

Are you absolutely 100% sure you want to proceed?

\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n ${!isInfoPanelEditing ? `\n
\n

${escapeHTML(node.name)}

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n
Name
\n
${escapeHTML(node.name)}
\n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n
Estimated Hours
\n
${node.estimatedHours || 0} hrs
\n
\n
\n
People
\n
${node.people || 1}
\n
\n
\n `}\n
\n
Description
\n
${escapeHTML(node.description || 'No description provided.')}
\n
\n ` : `\n
\n

Edit Item Details

\n
\n \n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n `}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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 = ``;\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
\n image\n No custom logo\n
\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
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeEmailPay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.email || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n \n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n
\n \n
\n \n × base pay\n
\n
\n \n
\n \n
\n $\n \n
\n
\n \n
\n \n \n
\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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
\n
\n

Business Asset Defaults

\n
Recovery Automation Enabled
\n
\n
\n

Configure the default recovery rates for your business equipment. These rates are used to calculate internal job costs when assets are assigned to tasks.

\n \n \n \n \n \n \n \n \n \n \n ${assets.map(a => `\n \n \n \n \n \n `).join('')}\n ${assets.length === 0 ? '' : ''}\n \n
Asset NameCurrent Rate ($/hr)Status
${escapeHTML(a.name)}\n
\n $\n \n
\n
Active
No business assets found. Add assets in the main Assets module.
\n
\n
\n \n
\n
\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
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n
\n
\n
\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n ${!isInfoPanelEditing ? `\n
\n

${escapeHTML(node.name)}

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n
Name
\n
${escapeHTML(node.name)}
\n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n
Estimated Hours
\n
${node.estimatedHours || 0} hrs
\n
\n
\n
People
\n
${node.people || 1}
\n
\n
\n `}\n
\n
Description
\n
${escapeHTML(node.description || 'No description provided.')}
\n
\n ` : `\n
\n

Edit Item Details

\n
\n \n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n `}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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 = ``;\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
\n image\n No custom logo\n
\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
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeEmailPay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.email || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n \n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n
\n \n
\n \n × base pay\n
\n
\n \n
\n \n
\n $\n \n
\n
\n \n
\n \n \n
\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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
\n
\n

Business Asset Defaults

\n
Recovery Automation Enabled
\n
\n
\n

Configure the default recovery rates for your business equipment. These rates are used to calculate internal job costs when assets are assigned to tasks.

\n \n \n \n \n \n \n \n \n \n \n ${assets.map(a => `\n \n \n \n \n \n `).join('')}\n ${assets.length === 0 ? '' : ''}\n \n
Asset NameCurrent Rate ($/hr)Status
${escapeHTML(a.name)}\n
\n $\n \n
\n
Active
No business assets found. Add assets in the main Assets module.
\n
\n
\n \n
\n
\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
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n
\n
\n
\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n ${!isInfoPanelEditing ? `\n
\n

${escapeHTML(node.name)}

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n
Name
\n
${escapeHTML(node.name)}
\n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n
Estimated Hours
\n
${node.estimatedHours || 0} hrs
\n
\n
\n
People
\n
${node.people || 1}
\n
\n
\n `}\n
\n
Description
\n
${escapeHTML(node.description || 'No description provided.')}
\n
\n ` : `\n
\n

Edit Item Details

\n
\n \n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n `}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n \n \n
\n
\n \n \n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n \n \n \n
\n
\n
\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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

Settings

\n\n
\n \n \n \n \n \n \n \n \n \n
\n
\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
\n

Company Information

\n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n\n \n
\n \n
\n ${pendingLogo ? `` : `\n
\n image\n No custom logo\n
\n `}\n
\n \n
\n \n ${pendingLogo ? `` : ''}\n
\n
UNSAVED PREVIEW
\n
\n
\n
\n
\n \n
\n
\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 = ``;\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
\n image\n No custom logo\n
\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
\n
\n

Active Users

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => !t.deactivated).map(t => {\n const ut = userTypes.find(ut => ut.id === t.userTypeId);\n return `\n \n \n \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleUser TypeEmailPay RateActions
${t.name}${t.role}${ut?.name || 'Unassigned'}${t.email || '-'}${t.payRate ? `$${t.payRate.toFixed(2)}/hr` : '-'}\n
\n \n \n
\n
\n
\n
\n\n
\n
\n

User Types & Permissions

\n \n
\n
\n \n \n \n ${userTypes.map(ut => `\n \n \n \n \n \n `).join('')}\n \n
NameDescriptionActions
${ut.name}${ut.description}\n
\n \n \n \n
\n
\n
\n
\n\n
\n

Deactivated Users (Cooldown Period)

\n
\n \n \n \n \n \n \n \n \n \n \n \n ${techs.filter(t => t.deactivated).length === 0 ? '' : ''}\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 \n \n \n \n \n \n \n `;\n }).join('')}\n \n
NameRoleDeactivated OnCooldown StatusActions
No deactivated users
${t.name}${t.role}${deactivatedAt.toLocaleDateString()}\n ${canReactivate \n ? 'Cooldown Complete' \n : `Available in ${remaining} days`}\n \n \n
\n
\n
\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 = `

Are you sure you want to deactivate ${t.name}? They will no longer be able to log in.

`;\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 = `

Reactivate ${t.name}? They will regain access once a User Type is assigned.

`;\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 = `

Are you sure you want to delete the user type ${ut.name}?${usersWithType.length > 0 ? ` ${usersWithType.length} user(s) will become unassigned.` : ''} This cannot be undone.

`;\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
\n
\n

Tax & Global Markup

\n
\n
\n \n
\n %\n
\n
\n
\n \n
\n %\n
\n
\n
\n
\n
\n

Labor Rounding Rules

\n
\n
\n \n \n

Standardizes billing and ensures technicians are paid consistently for small increments.

\n
\n
\n
\n
\n\n
\n
\n
\n

Labour Rate Profiles

\n

Define charge-out rates for different job types or time periods. These appear as selectable options when adding labour to a quote or job.

\n
\n \n
\n
\n
\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
\n \n
\n
\n sell\n \n ${rate.isDefault ? 'DEFAULT' : ''}\n
\n
\n ${!rate.isDefault ? `` : ''}\n \n
\n
\n \n
\n \n
\n \n
\n $\n \n /hr\n
\n
\n \n
\n \n
\n \n × base pay\n
\n
\n \n
\n \n
\n $\n \n
\n
\n \n
\n \n \n
\n \n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\n
\n `}).join('')}\n
\n
\n
\n \n
\n
\n\n
\n

Job Type Rate Mapping

\n
\n

Automatically assign a labor profile when a job of a specific type is created.

\n
\n ${['Service', 'Project', 'Maintenance', 'Quote'].map(type => `\n
\n \n \n
\n `).join('')}\n
\n
\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
\n
\n sell\n \n
\n
\n \n \n
\n
\n
\n
\n \n
\n $\n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n \n
\n ${allDays.map(day => `\n \n `).join('')}\n
\n
\n
\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
\n
\n

Business Asset Defaults

\n
Recovery Automation Enabled
\n
\n
\n

Configure the default recovery rates for your business equipment. These rates are used to calculate internal job costs when assets are assigned to tasks.

\n \n \n \n \n \n \n \n \n \n \n ${assets.map(a => `\n \n \n \n \n \n `).join('')}\n ${assets.length === 0 ? '' : ''}\n \n
Asset NameCurrent Rate ($/hr)Status
${escapeHTML(a.name)}\n
\n $\n \n
\n
Active
No business assets found. Add assets in the main Assets module.
\n
\n
\n \n
\n
\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
\n

Data Management

\n
\n

Manage your application data. All data is stored locally in your browser.

\n
\n \n \n
\n
\n
\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
\n \n \n \n
\n ` : ''}\n
\n \n \n
\n
\n \n \n
\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 \n `).join('');\n return `\n
\n
\n ${module}\n \n
\n
\n ${permCheckboxes}\n
\n
\n `;\n }).join('');\n\n contentDiv.innerHTML = `\n
\n \n \n
\n
\n ${moduleSections}\n
\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
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n
\n $\n \n /hr — used in job cost & P&L calculations\n
\n
\n
\n \n
\n ${['#1B6DE0', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#64748B', '#0EA5E9'].map(c => `\n
\n `).join('')}\n
\n colorize\n \n
\n
\n
\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
\n
\n

Tasklist Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionTagsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n
\n ${(t.tags || []).map(tag => `${escapeHTML(tag)}`).join('')}\n
\n
\n \n \n
No templates saved yet.
\n
\n
\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
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n\n
\n \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
\n
\n
\n ${taskViewPath.length > 0 ? `` : ''}\n ${viewTitle}\n
\n \n
\n
\n ${viewList.map((p, i) => {\n const currentPath = [...taskViewPath, i];\n const isSelected = currentPath.join('-') === taskExpandedPath.join('-');\n return `\n
\n ${escapeHTML(p.name)}\n ${p.subTasks && p.subTasks.length > 0 ? `` : ''}\n
\n `;\n }).join('')}\n ${viewList.length === 0 ? '
No items. Click + to add.
' : ''}\n
\n
\n `;\n })()}\n\n \n
\n ${taskExpandedPath.length > 0 ? (() => {\n const path = taskExpandedPath;\n const node = getTaskByPath(localTasks, path);\n if (!node) return '
Selected task not found.
';\n const hasSubs = node.subTasks && node.subTasks.length > 0;\n \n return `\n ${!isInfoPanelEditing ? `\n
\n

${escapeHTML(node.name)}

\n
\n ${path.length < 3 ? `` : ''}\n \n \n
\n
\n
\n
Name
\n
${escapeHTML(node.name)}
\n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n
Estimated Hours
\n
${node.estimatedHours || 0} hrs
\n
\n
\n
People
\n
${node.people || 1}
\n
\n
\n `}\n
\n
Description
\n
${escapeHTML(node.description || 'No description provided.')}
\n
\n ` : `\n
\n

Edit Item Details

\n
\n \n \n \n
\n
\n
\n \n \n
\n ${hasSubs ? `\n
\n
Total Hours (Rollup)
\n
${calculateTotalHours(node)} hrs
\n
\n ` : `\n
\n
\n \n \n
\n
\n \n \n
\n
\n `}\n
\n \n \n
\n `}\n `;\n })() : '
Add or select a task on the left to edit details.
'}\n
\n
\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
\n
\n

Quote Templates

\n \n
\n
\n \n \n \n \n \n \n \n \n \n ${templates.length ? templates.map(t => `\n \n \n \n \n \n `).join('') : ''}\n \n
Template NameDescriptionActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}\n \n \n
No quote templates saved yet.
\n
\n
\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
\n \n \n
\n
\n \n \n
\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
\n
\n

Markup Configuration

\n
\n
\n
\n \n
\n \n %\n
\n

Applied to items not covered by tiers or categories.

\n
\n
\n \n
\n $\n \n
\n

Ensures a base profit on even the smallest components.

\n
\n
\n\n
\n
\n
\n
Tiered Pricing
\n

Automatically adjust markup based on the unit cost of the item.

\n
\n \n
\n\n
\n \n \n \n \n \n \n \n \n \n ${(markup.tiers || []).map((t, i) => `\n \n \n \n \n \n `).join('')}\n \n
Item Cost RangeMarkup %
\n
\n ${i === 0 ? 'Up to' : 'From previous up to'} \n
\n $\n \n
\n
\n
\n
\n \n %\n
\n
\n \n
\n \n
\n
\n
\n
\n\n
\n

Material Categories

\n
\n

Group items for reporting and bulk adjustments.

\n
\n ${categories.map(c => `\n
\n ${c}\n close\n
\n `).join('')}\n \n
\n
\n
\n\n
\n \n
\n
\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 \n
\n From previous up to \n
\n $\n \n
\n
\n \n \n
\n \n %\n
\n \n \n \n \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 close\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
\n
\n

Custom Form Templates

\n \n
\n
\n
\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
\n \n \n \n \n \n \n \n \n \n \n ${templates.map(t => `\n \n \n \n \n \n \n `).join('')}\n ${!templates.length ? '' : ''}\n \n
Form NameDescriptionFieldsActions
${escapeHTML(t.name)}${escapeHTML(t.description || '—')}${(t.sections || []).reduce((sum, s) => sum + s.fields.length, 0)} Fields\n \n \n
No form templates created yet.
\n
\n
\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"}}}}