// ── Shared util ─────────────────────────────────────────────────────────────── const PAGE_SIZES = [10, 25, 50, 100]; const PAGE_SIZES_SM = [5, 10, 25, 50]; function _postDelete(action) { const form = document.createElement('form'); form.method = 'POST'; form.action = action; const t = document.createElement('input'); t.type = 'hidden'; t.name = '_token'; t.value = window.__csrf || ''; form.appendChild(t); document.body.appendChild(form); form.submit(); } function _escapeHtml(value) { return String(value).replace(/[&<>"']/g, function (char) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[char]; }); } // ── Campaign Type ───────────────────────────────────────────────────────────── window.campaignTypeTable = function () { return { table: null, init() { this.initTable(); }, initTable() { const el = document.getElementById('campaign-type-table'); if (!el || typeof Tabulator === 'undefined') { return; } this.table = new Tabulator(el, { ajaxURL: '/campaign-types/data', layout: 'fitColumns', responsiveLayout: 'collapse', pagination: true, paginationMode: 'local', paginationSize: 10, paginationSizeSelector: PAGE_SIZES, movableColumns: true, placeholder: 'No campaign types found.', initialSort: [{ column: 'name', dir: 'asc' }], columns: [ { title: 'Actions', field: 'id', width: 160, hozAlign: 'center', headerSort: false, formatter: function (cell) { const id = cell.getValue(); return 'Edit ' + ''; }, }, { title: 'Name', field: 'name', minWidth: 200 }, { title: 'Attributes', field: 'attributes_summary', minWidth: 240, formatter: function (cell) { const v = cell.getValue(); return v ? '' + _escapeHtml(v) + '' : ''; }, }, { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 }, { title: 'Created', field: 'created_at', minWidth: 160 }, ], }); }, reloadTable() { if (!this.table) { this.initTable(); return; } this.table.setData('/campaign-types/data'); }, }; }; window.deleteCampaignType = function (id) { if (!confirm('Delete this campaign type? This cannot be undone.')) { return; } _postDelete('/campaign-types/' + id + '/delete'); }; window.campaignTypeForm = function (initialAttributes) { return { attributes: Array.isArray(initialAttributes) ? initialAttributes : [], dragIndex: null, dragOverIndex: null, addAttribute() { this.attributes.push({ name: '', type: 'text', order: this.attributes.length + 1 }); }, removeAttribute(index) { this.attributes.splice(index, 1); this.renumberOrder(); }, renumberOrder() { this.attributes.forEach(function (attr, i) { attr.order = i + 1; }); }, dragStart(event, index) { this.dragIndex = index; event.dataTransfer.effectAllowed = 'move'; }, dragOver(event, index) { this.dragOverIndex = index; }, drop(event, index) { if (this.dragIndex !== null && this.dragIndex !== index) { var moved = this.attributes.splice(this.dragIndex, 1)[0]; this.attributes.splice(index, 0, moved); this.renumberOrder(); } this.dragIndex = null; this.dragOverIndex = null; }, dragEnd() { this.dragIndex = null; this.dragOverIndex = null; }, confirmDelete(event) { if (confirm('Delete this campaign type? This cannot be undone.')) { event.target.submit(); } }, }; }; // ── Campaign ────────────────────────────────────────────────────────────────── window.campaignTable = function () { return { table: null, jobsTable: null, isLoading: false, isJobsLoading: false, errorMessage: '', jobsErrorMessage: '', selectedCampaignId: null, selectedCampaignTitle: '', init() { this.loadTable(); }, async loadTable() { const el = document.getElementById('campaign-table'); if (!el || typeof Tabulator === 'undefined' || this.isLoading) { return; } this.isLoading = true; this.errorMessage = ''; try { const response = await fetch('/campaigns/data', { headers: { Accept: 'application/json' }, }); if (!response.ok) { throw new Error('Unable to load campaigns.'); } const rows = await response.json(); const campaignRows = Array.isArray(rows) ? rows : []; const attributes = this.attributeColumnsForRows(campaignRows); const tableRows = this.formatRows(campaignRows, attributes); const columns = this.columnsForAttributes(attributes); if (!this.table) { this.table = new Tabulator(el, { data: tableRows, layout: 'fitColumns', responsiveLayout: 'collapse', pagination: true, paginationMode: 'local', paginationSize: 10, paginationSizeSelector: PAGE_SIZES, movableColumns: true, placeholder: 'No campaigns found.', initialSort: [{ column: 'campaign_type_name', dir: 'asc' }], columns: columns, }); this.table.on('rowClick', (event, row) => this.goToCampaignJobs(event, row)); } else { this.table.setColumns(columns); this.table.setData(tableRows); } } catch (error) { this.errorMessage = error.message || 'Unable to load campaigns.'; } finally { this.isLoading = false; } }, attributeColumnsForRows(rows) { const attributes = []; rows.forEach((row) => { this.normalizeAttributes(row.campaign_type_attributes || []).forEach((attr) => { if (!attributes.some((existing) => existing.name === attr.name)) { attributes.push(attr); } }); Object.keys(row.attribute_values || {}).forEach((name) => { if (!attributes.some((existing) => existing.name === name)) { attributes.push({ name: name, type: 'text', order: attributes.length + 1 }); } }); }); return attributes; }, normalizeAttributes(attributes) { return attributes .filter((attr) => attr && attr.name) .slice() .sort((a, b) => (a.order || 0) - (b.order || 0)); }, formatRows(rows, attributes) { return rows.map((row) => { const attributeValues = row.attribute_values || {}; const tableRow = { id: row.id, campaign_type_name: row.campaign_type_name || '', created_at: row.created_at || '', }; attributes.forEach((attr, index) => { tableRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? ''); }); return tableRow; }); }, formatAttributeValue(value) { if (value === null || value === undefined) { return ''; } if (Array.isArray(value) || typeof value === 'object') { return JSON.stringify(value); } return String(value); }, columnsForAttributes(attributes) { const columns = [ { title: 'Actions', field: 'id', width: 230, hozAlign: 'center', headerSort: false, formatter: function (cell) { const id = cell.getValue(); return 'Jobs ' + 'Edit ' + ''; }, }, { title: 'Campaign Type', field: 'campaign_type_name', minWidth: 160, headerFilter: 'input', }, ]; attributes.forEach((attr, index) => { columns.push({ title: attr.name, field: 'attr_' + index, minWidth: 150, headerFilter: 'input', formatter: function (cell) { const value = cell.getValue(); return value ? _escapeHtml(value) : ''; }, }); }); columns.push( { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' } ); return columns; }, goToCampaignJobs(event, row) { const target = event.target; if (target instanceof Element && target.closest('a, button')) { return; } window.location.href = '/campaigns/' + encodeURIComponent(row.getData().id) + '/jobs'; }, reloadTable() { this.loadTable(); }, openCampaignJobs(campaign) { this.selectedCampaignId = campaign.id; this.selectedCampaignTitle = 'Campaign #' + campaign.id + ' - ' + campaign.campaign_type_name; this.$nextTick(() => this.loadJobsTable()); }, async reloadJobsTable() { if (!this.selectedCampaignId) { return; } await this.loadJobsTable(); }, closeJobsTable() { this.selectedCampaignId = null; this.selectedCampaignTitle = ''; this.jobsErrorMessage = ''; if (this.jobsTable && typeof this.jobsTable.destroy === 'function') { this.jobsTable.destroy(); } this.jobsTable = null; }, async loadJobsTable() { const el = document.getElementById('campaign-jobs-drilldown-table'); if (!el || typeof Tabulator === 'undefined' || this.isJobsLoading) { return; } this.isJobsLoading = true; this.jobsErrorMessage = ''; try { const response = await fetch('/campaigns/' + encodeURIComponent(this.selectedCampaignId) + '/jobs/data', { headers: { Accept: 'application/json' }, }); if (!response.ok) { throw new Error('Unable to load campaign jobs.'); } const rows = await response.json(); const jobRows = Array.isArray(rows) ? rows : []; const attributes = this.jobAttributeColumnsForRows(jobRows); const tableRows = this.formatJobRows(jobRows, attributes); const columns = this.jobColumnsForAttributes(attributes); if (!this.jobsTable) { this.jobsTable = new Tabulator(el, { data: tableRows, layout: 'fitColumns', responsiveLayout: 'collapse', pagination: true, paginationMode: 'local', paginationSize: 10, paginationSizeSelector: PAGE_SIZES, movableColumns: true, placeholder: 'No jobs found for this campaign.', initialSort: [{ column: 'job_type_name', dir: 'asc' }], columns: columns, }); } else { this.jobsTable.setColumns(columns); this.jobsTable.setData(tableRows); } } catch (error) { this.jobsErrorMessage = error.message || 'Unable to load campaign jobs.'; } finally { this.isJobsLoading = false; } }, jobAttributeColumnsForRows(rows) { const attributes = []; rows.forEach((row) => { this.normalizeAttributes(row.job_type_attributes || []).forEach((attr) => { if (!attributes.some((existing) => existing.name === attr.name)) { attributes.push(attr); } }); Object.keys(row.attribute_values || {}).forEach((name) => { if (!attributes.some((existing) => existing.name === name)) { attributes.push({ name: name, type: 'text', order: attributes.length + 1 }); } }); }); return attributes; }, formatJobRows(rows, attributes) { return rows.map((row) => { const attributeValues = row.attribute_values || {}; const tableRow = { id: row.id, campaign_id: row.campaign_id || '', job_type_id: row.job_type_id || '', job_type_name: row.job_type_name || '', created_at: row.created_at || '', updated_at: row.updated_at || '', edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit', }; attributes.forEach((attr, index) => { tableRow['job_attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? ''); }); return tableRow; }); }, jobColumnsForAttributes(attributes) { const columns = [ { title: 'Actions', field: 'edit_url', width: 90, hozAlign: 'center', headerSort: false, formatter: function (cell) { return 'Edit'; }, }, { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' }, { title: 'Campaign ID', field: 'campaign_id', width: 120, hozAlign: 'center', headerFilter: 'input' }, { title: 'Job Type ID', field: 'job_type_id', width: 120, hozAlign: 'center', headerFilter: 'input' }, { title: 'Job Type', field: 'job_type_name', minWidth: 160, headerFilter: 'input' }, ]; attributes.forEach((attr, index) => { columns.push({ title: attr.name, field: 'job_attr_' + index, minWidth: 150, headerFilter: 'input', formatter: function (cell) { const value = cell.getValue(); return value ? _escapeHtml(value) : ''; }, }); }); columns.push( { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' }, { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' } ); return columns; }, }; }; window.deleteCampaign = function (id) { if (!confirm('Delete this campaign? This cannot be undone.')) { return; } _postDelete('/campaigns/' + id + '/delete'); }; window.campaignJobsPageTable = function (campaignId, jobTypes) { return { table: null, jobTypes: Array.isArray(jobTypes) ? jobTypes : [], isLoading: false, isConnecting: false, isImporting: false, errorMessage: '', importSheetUrl: '', sheets: [], selectedSheetGid: '', selectedImportJobTypeId: '0', importMessage: '', importErrorMessage: '', // File upload state importSource: 'sheets', fileSelected: false, fileTempName: '', fileSheets: [], selectedFileSheetGid: '', selectedFileJobTypeId: '0', isLoadingFile: false, isImportingFile: false, init() { this.loadTable(); }, dataUrl() { return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/data'; }, sheetsUrl() { return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import/sheets'; }, importUrl() { return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import'; }, async loadTable() { const el = document.getElementById('campaign-jobs-page-table'); if (!el || typeof Tabulator === 'undefined' || this.isLoading) { return; } this.isLoading = true; this.errorMessage = ''; try { const response = await fetch(this.dataUrl(), { headers: { Accept: 'application/json' }, }); if (!response.ok) { throw new Error('Unable to load campaign jobs.'); } const rows = await response.json(); const jobRows = Array.isArray(rows) ? rows : []; const attributes = this.attributeColumnsForRows(jobRows); const tableRows = this.formatRows(jobRows, attributes); const columns = this.columnsForAttributes(attributes); if (!this.table) { this.table = new Tabulator(el, { data: tableRows, layout: 'fitData', maxHeight: '65vh', pagination: true, paginationMode: 'local', paginationSize: 10, paginationSizeSelector: PAGE_SIZES, movableColumns: true, placeholder: 'No jobs found for this campaign.', initialSort: [{ column: 'job_type_name', dir: 'asc' }], columns: columns, }); } else { this.table.setColumns(columns); this.table.setData(tableRows); } } catch (error) { this.errorMessage = error.message || 'Unable to load campaign jobs.'; } finally { this.isLoading = false; } }, attributeColumnsForRows(rows) { const attributes = []; rows.forEach((row) => { this.normalizeAttributes(row.job_type_attributes || []).forEach((attr) => { if (!attributes.some((existing) => existing.name === attr.name)) { attributes.push(attr); } }); Object.keys(row.attribute_values || {}).forEach((name) => { if (!attributes.some((existing) => existing.name === name)) { attributes.push({ name: name, type: 'text', order: attributes.length + 1 }); } }); }); return attributes; }, normalizeAttributes(attributes) { return attributes .filter((attr) => attr && attr.name) .slice() .sort((a, b) => (a.order || 0) - (b.order || 0)); }, formatRows(rows, attributes) { return rows.map((row) => { const attributeValues = row.attribute_values || {}; const tableRow = { id: row.id, campaign_id: row.campaign_id || '', campaign_type_name: row.campaign_type_name || '', job_type_id: row.job_type_id || '', job_type_name: row.job_type_name || '', created_at: row.created_at || '', updated_at: row.updated_at || '', edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit', }; attributes.forEach((attr, index) => { tableRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? ''); }); return tableRow; }); }, formatAttributeValue(value) { if (value === null || value === undefined) { return ''; } if (Array.isArray(value) || typeof value === 'object') { return JSON.stringify(value); } return String(value); }, columnsForAttributes(attributes) { const columns = [ { title: 'Actions', field: 'edit_url', width: 90, hozAlign: 'center', headerSort: false, formatter: function (cell) { return 'Edit'; }, }, { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' }, { title: 'Campaign ID', field: 'campaign_id', width: 120, hozAlign: 'center', headerFilter: 'input' }, { title: 'Campaign Type', field: 'campaign_type_name', minWidth: 160, headerFilter: 'input' }, { title: 'Job Type ID', field: 'job_type_id', width: 120, hozAlign: 'center', headerFilter: 'input' }, { title: 'Job Type', field: 'job_type_name', minWidth: 160, headerFilter: 'input' }, ]; attributes.forEach((attr, index) => { columns.push({ title: attr.name, field: 'attr_' + index, minWidth: 150, headerFilter: 'input', formatter: function (cell) { const value = cell.getValue(); return value ? _escapeHtml(value) : ''; }, }); }); columns.push( { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' }, { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' } ); return columns; }, reloadTable() { this.loadTable(); }, async connectGoogleSheet() { this.isConnecting = true; this.importMessage = ''; this.importErrorMessage = ''; this.sheets = []; this.selectedSheetGid = ''; try { const data = await this.postImportForm(this.sheetsUrl(), { sheet_url: this.importSheetUrl, }); this.sheets = Array.isArray(data.sheets) ? data.sheets : []; if (this.sheets.length > 0) { this.selectedSheetGid = this.sheets[0].gid; this.importMessage = 'Connected. Select a sheet and job type to import.'; } else { this.importErrorMessage = 'No sheets were found in that Google Sheets file.'; } } catch (error) { this.importErrorMessage = error.message || 'Unable to connect to Google Sheets.'; } finally { this.isConnecting = false; } }, async importGoogleSheet() { this.isImporting = true; this.importMessage = ''; this.importErrorMessage = ''; try { const data = await this.postImportForm(this.importUrl(), { sheet_url: this.importSheetUrl, sheet_gid: this.selectedSheetGid, job_type_id: this.selectedImportJobTypeId, }); const matched = Array.isArray(data.matched_attributes) ? data.matched_attributes.join(', ') : ''; this.importMessage = 'Imported ' + data.imported + ' jobs. Skipped ' + data.skipped + ' empty rows.' + (matched ? ' Matched: ' + matched + '.' : ''); await this.loadTable(); } catch (error) { this.importErrorMessage = error.message || 'Unable to import Google Sheet.'; } finally { this.isImporting = false; } }, async postImportForm(url, fields) { const body = new URLSearchParams(); body.set('_token', window.__csrf || ''); Object.keys(fields).forEach((key) => { body.set(key, fields[key] || ''); }); const response = await fetch(url, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', }, body: body.toString(), }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.error || 'Import failed.'); } return data; }, // ── File upload methods ─────────────────────────────────────────────── onFileSelect(event) { this.fileSelected = event.target.files && event.target.files.length > 0; this.fileTempName = ''; this.fileSheets = []; this.selectedFileSheetGid = ''; this.importMessage = ''; this.importErrorMessage = ''; }, async loadFileSheets() { this.isLoadingFile = true; this.importMessage = ''; this.importErrorMessage = ''; this.fileTempName = ''; this.fileSheets = []; this.selectedFileSheetGid = ''; try { const fileInput = this.$refs.fileInput; if (!fileInput || !fileInput.files || fileInput.files.length === 0) { throw new Error('No file selected.'); } const form = new FormData(); form.set('_token', window.__csrf || ''); form.set('import_file', fileInput.files[0]); const url = '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import/file/sheets'; const response = await fetch(url, { method: 'POST', headers: { Accept: 'application/json' }, body: form, }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.error || 'Could not read the file.'); } this.fileTempName = data.temp_file || ''; this.fileSheets = Array.isArray(data.sheets) ? data.sheets : []; if (this.fileSheets.length > 0) { this.selectedFileSheetGid = this.fileSheets[0].gid; this.importMessage = 'File loaded. Select a sheet and job type to import.'; } else { this.importErrorMessage = 'No sheets were found in the uploaded file.'; } } catch (error) { this.importErrorMessage = error.message || 'Could not read the file.'; } finally { this.isLoadingFile = false; } }, async importFile() { this.isImportingFile = true; this.importMessage = ''; this.importErrorMessage = ''; try { const url = '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import/file'; const data = await this.postImportForm(url, { temp_file: this.fileTempName, sheet_gid: this.selectedFileSheetGid, job_type_id: this.selectedFileJobTypeId, }); const matched = Array.isArray(data.matched_attributes) ? data.matched_attributes.join(', ') : ''; this.importMessage = 'Imported ' + data.imported + ' jobs. Skipped ' + data.skipped + ' empty rows.' + (matched ? ' Matched: ' + matched + '.' : ''); this.fileTempName = ''; await this.loadTable(); } catch (error) { this.importErrorMessage = error.message || 'Import failed.'; } finally { this.isImportingFile = false; } }, }; }; window.campaignForm = function (types, initialTypeId, initialValues) { return { types: types, selectedTypeId: String(initialTypeId || ''), attributeValues: Object.assign({}, initialValues || {}), get currentType() { var id = this.selectedTypeId; if (!id) return null; return this.types.find(function (t) { return String(t.id) === String(id); }) || null; }, get currentAttributes() { if (!this.currentType) return []; return this.currentType.attributes.slice().sort(function (a, b) { return (a.order || 0) - (b.order || 0); }); }, onTypeChange() { this.attributeValues = {}; }, inputType(attrType) { return ['number', 'date'].includes(attrType) ? attrType : 'text'; }, confirmDelete(event) { if (confirm('Delete this campaign? This cannot be undone.')) { event.target.submit(); } }, }; }; // Campaign Jobs window.campaignJobsTable = function (campaignId) { return { tables: {}, groups: [], isVisible: false, isLoading: false, hasLoaded: false, errorMessage: '', dataUrl() { return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/data'; }, async showTable() { this.isVisible = true; if (!this.hasLoaded) { await this.loadGroups(); } }, hideTable() { this.isVisible = false; }, async reloadTable() { this.isVisible = true; await this.loadGroups(); }, async loadGroups() { this.isLoading = true; this.errorMessage = ''; this.destroyTables(); try { const response = await fetch(this.dataUrl(), { headers: { Accept: 'application/json' }, }); if (!response.ok) { throw new Error('Unable to load campaign jobs.'); } const rows = await response.json(); this.groups = this.groupRows(Array.isArray(rows) ? rows : []); this.hasLoaded = true; this.$nextTick(() => this.initTables()); } catch (error) { this.groups = []; this.errorMessage = error.message || 'Unable to load campaign jobs.'; } finally { this.isLoading = false; } }, groupRows(rows) { const groups = {}; rows.forEach((row) => { const id = String(row.job_type_id || 0); if (!groups[id]) { groups[id] = { id: id, elementId: 'campaign-jobs-table-' + id, name: row.job_type_name || 'Job Type #' + id, attributes: this.normalizeAttributes(row.job_type_attributes || []), rows: [], }; } const attributeValues = row.attribute_values || {}; this.ensureAttributeColumns(groups[id], attributeValues); const gridRow = { id: row.id, created_at: row.created_at || '', edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit', }; groups[id].attributes.forEach((attr, index) => { gridRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? ''); }); groups[id].rows.push(gridRow); }); return Object.values(groups).sort((a, b) => a.name.localeCompare(b.name)); }, ensureAttributeColumns(group, attributeValues) { Object.keys(attributeValues).forEach((name) => { const exists = group.attributes.some((attr) => attr.name === name); if (!exists) { group.attributes.push({ name: name, type: 'text', order: group.attributes.length + 1, }); } }); }, normalizeAttributes(attributes) { return attributes .filter((attr) => attr && attr.name) .slice() .sort((a, b) => (a.order || 0) - (b.order || 0)); }, formatAttributeValue(value) { if (value === null || value === undefined) { return ''; } if (Array.isArray(value) || typeof value === 'object') { return JSON.stringify(value); } return String(value); }, initTables() { if (typeof Tabulator === 'undefined') { return; } this.groups.forEach((group) => { const el = document.getElementById(group.elementId); if (!el || this.tables[group.id]) { return; } this.tables[group.id] = new Tabulator(el, { data: group.rows, layout: 'fitColumns', responsiveLayout: 'collapse', pagination: true, paginationMode: 'local', paginationSize: 5, paginationSizeSelector: PAGE_SIZES_SM, movableColumns: true, placeholder: 'No jobs found for this job type.', initialSort: [{ column: 'created_at', dir: 'desc' }], columns: this.columnsForGroup(group), }); }); }, columnsForGroup(group) { const actions = { title: 'Actions', field: 'edit_url', width: 90, hozAlign: 'center', headerSort: false, formatter: function (cell) { return 'Edit'; }, }; const attrColumns = group.attributes.map((attr, index) => ({ title: attr.name, field: 'attr_' + index, minWidth: 150, formatter: function (cell) { const value = cell.getValue(); return value ? _escapeHtml(value) : ''; }, })); if (attrColumns.length === 0) { attrColumns.push({ title: 'Job ID', field: 'id', width: 90, hozAlign: 'center' }); } return [actions, ...attrColumns, { title: 'Created', field: 'created_at', minWidth: 160 }]; }, destroyTables() { Object.values(this.tables).forEach((table) => { if (table && typeof table.destroy === 'function') { table.destroy(); } }); this.tables = {}; }, }; }; // Job Type window.jobTypeTable = function () { return { table: null, init() { this.initTable(); }, initTable() { const el = document.getElementById('job-type-table'); if (!el || typeof Tabulator === 'undefined') { return; } this.table = new Tabulator(el, { ajaxURL: '/job-types/data', layout: 'fitColumns', responsiveLayout: 'collapse', pagination: true, paginationMode: 'local', paginationSize: 10, paginationSizeSelector: PAGE_SIZES, movableColumns: true, placeholder: 'No job types found.', initialSort: [{ column: 'name', dir: 'asc' }], columns: [ { title: 'Actions', field: 'id', width: 160, hozAlign: 'center', headerSort: false, formatter: function (cell) { const id = cell.getValue(); return 'Edit ' + ''; }, }, { title: 'Name', field: 'name', minWidth: 200 }, { title: 'Attributes', field: 'attributes_summary', minWidth: 240, formatter: function (cell) { const v = cell.getValue(); return v ? '' + _escapeHtml(v) + '' : ''; }, }, { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 }, { title: 'Created', field: 'created_at', minWidth: 160 }, ], }); }, reloadTable() { if (!this.table) { this.initTable(); return; } this.table.setData('/job-types/data'); }, }; }; window.deleteJobType = function (id) { if (!confirm('Delete this job type? This cannot be undone.')) { return; } _postDelete('/job-types/' + id + '/delete'); }; window.jobTypeForm = function (initialAttributes) { return { attributes: Array.isArray(initialAttributes) ? initialAttributes : [], dragIndex: null, dragOverIndex: null, addAttribute() { this.attributes.push({ name: '', type: 'text', order: this.attributes.length + 1 }); }, removeAttribute(index) { this.attributes.splice(index, 1); this.renumberOrder(); }, renumberOrder() { this.attributes.forEach(function (attr, i) { attr.order = i + 1; }); }, dragStart(event, index) { this.dragIndex = index; event.dataTransfer.effectAllowed = 'move'; }, dragOver(event, index) { this.dragOverIndex = index; }, drop(event, index) { if (this.dragIndex !== null && this.dragIndex !== index) { var moved = this.attributes.splice(this.dragIndex, 1)[0]; this.attributes.splice(index, 0, moved); this.renumberOrder(); } this.dragIndex = null; this.dragOverIndex = null; }, dragEnd() { this.dragIndex = null; this.dragOverIndex = null; }, confirmDelete(event) { if (confirm('Delete this job type? This cannot be undone.')) { event.target.submit(); } }, }; }; // ── Job ─────────────────────────────────────────────────────────────────────── window.jobTable = function () { return { table: null, init() { this.initTable(); }, initTable() { const el = document.getElementById('job-table'); if (!el || typeof Tabulator === 'undefined') { return; } this.table = new Tabulator(el, { ajaxURL: '/jobs/data', layout: 'fitColumns', responsiveLayout: 'collapse', pagination: true, paginationMode: 'local', paginationSize: 10, paginationSizeSelector: PAGE_SIZES, movableColumns: true, placeholder: 'No jobs found.', initialSort: [{ column: 'job_type_name', dir: 'asc' }], columns: [ { title: 'Actions', field: 'id', width: 160, hozAlign: 'center', headerSort: false, formatter: function (cell) { const id = cell.getValue(); return 'Edit ' + ''; }, }, { title: 'Campaign', field: 'campaign_type_name', minWidth: 160 }, { title: 'Job Type', field: 'job_type_name', minWidth: 160 }, { title: 'Attributes', field: 'attributes_summary', minWidth: 220, formatter: function (cell) { const v = cell.getValue(); return v ? '' + _escapeHtml(v) + '' : ''; }, }, { title: 'Created', field: 'created_at', minWidth: 160 }, ], }); }, reloadTable() { if (!this.table) { this.initTable(); return; } this.table.setData('/jobs/data'); }, }; }; window.deleteJob = function (id) { if (!confirm('Delete this job? This cannot be undone.')) { return; } _postDelete('/jobs/' + id + '/delete'); }; window.jobForm = function (jobTypes, initialTypeId, initialValues) { return { jobTypes: jobTypes, selectedTypeId: String(initialTypeId || ''), attributeValues: Object.assign({}, initialValues || {}), get currentType() { var id = this.selectedTypeId; if (!id) return null; return this.jobTypes.find(function (t) { return String(t.id) === String(id); }) || null; }, get currentAttributes() { if (!this.currentType) return []; return this.currentType.attributes.slice().sort(function (a, b) { return (a.order || 0) - (b.order || 0); }); }, onTypeChange() { this.attributeValues = {}; }, inputType(attrType) { return ['number', 'date'].includes(attrType) ? attrType : 'text'; }, confirmDelete(event) { if (confirm('Delete this job? This cannot be undone.')) { event.target.submit(); } }, }; };