|
- // ── 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();
- }
-
- // Returns every {value, row} pair where `key` has a primitive value anywhere in the tree.
- // `row` is the nearest plain-object ancestor that directly owns `key`.
- function _deepFindRows(obj, key, out) {
- out = out || [];
- if (obj === null || typeof obj !== 'object') return out;
- if (Array.isArray(obj)) {
- for (var i = 0; i < obj.length; i++) { _deepFindRows(obj[i], key, out); }
- return out;
- }
- if (Object.prototype.hasOwnProperty.call(obj, key)) {
- var v = obj[key];
- if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
- out.push({ value: String(v), row: obj });
- }
- }
- for (var k in obj) {
- if (Object.prototype.hasOwnProperty.call(obj, k)) { _deepFindRows(obj[k], key, out); }
- }
- return out;}
-
- 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 '<a href="/campaign-types/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' +
- '<button onclick="window.deleteCampaignType(' + id + ')" class="button button-danger button-sm">Delete</button>';
- },
- },
- { title: 'Name', field: 'name', minWidth: 200 },
- {
- title: 'Attributes',
- field: 'attributes_summary',
- minWidth: 240,
- formatter: function (cell) {
- const v = cell.getValue();
- return v ? '<span class="attr-summary">' + _escapeHtml(v) + '</span>'
- : '<span class="attr-empty">—</span>';
- },
- },
- { 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 '<a href="/campaigns/' + id + '/jobs" class="button button-primary button-sm">Jobs</a> ' +
- '<a href="/campaigns/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' +
- '<button onclick="window.deleteCampaign(' + id + ')" class="button button-danger button-sm">Delete</button>';
- },
- },
- {
- 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) : '<span class="attr-empty">—</span>';
- },
- });
- });
-
- 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 '<a href="' + _escapeHtml(cell.getValue()) + '" class="button button-secondary button-sm">Edit</a>';
- },
- },
- { 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) : '<span class="attr-empty">—</span>';
- },
- });
- });
-
- 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 '<a href="' + _escapeHtml(cell.getValue()) + '" class="button button-secondary button-sm">Edit</a>';
- },
- },
- { 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) : '<span class="attr-empty">—</span>';
- },
- });
- });
-
- 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 '<a href="' + _escapeHtml(cell.getValue()) + '" class="button button-secondary button-sm">Edit</a>';
- },
- };
-
- 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) : '<span class="attr-empty">—</span>';
- },
- }));
-
- 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 '<a href="/job-types/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' +
- '<button onclick="window.deleteJobType(' + id + ')" class="button button-danger button-sm">Delete</button>';
- },
- },
- { title: 'Name', field: 'name', minWidth: 200 },
- {
- title: 'Attributes',
- field: 'attributes_summary',
- minWidth: 240,
- formatter: function (cell) {
- const v = cell.getValue();
- return v ? '<span class="attr-summary">' + _escapeHtml(v) + '</span>'
- : '<span class="attr-empty">—</span>';
- },
- },
- { 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, customerTypes) {
- return {
- attributes: Array.isArray(initialAttributes) ? initialAttributes : [],
- customerTypes: Array.isArray(customerTypes) ? customerTypes : [],
- dragIndex: null,
- dragOverIndex: null,
-
- addAttribute() {
- this.attributes.push({
- name: '', type: 'text', alias: '', order: this.attributes.length + 1,
- api_url: '', api_match_field: '', api_auto_fill: '', api_format: 'json', api_return_type: 'text',
- customer_type_id: 0,
- db_match_field: '', db_auto_fill: '', db_customer_type_id: 0,
- _uid: Math.random().toString(36).slice(2), _imported_by: '',
- });
- },
-
- importCustomerTypeAttributes(index) {
- var row = this.attributes[index];
- if (!row) return;
-
- var ctId = Number(row.customer_type_id || 0);
- if (!ctId) return;
-
- var ct = this.customerTypes.find(function (c) { return Number(c.id) === ctId; });
- if (!ct || !Array.isArray(ct.attributes) || ct.attributes.length === 0) {
- row.customer_type_id = 0;
- return;
- }
-
- var imported = ct.attributes.slice().sort(function (a, b) {
- return (a.order || 0) - (b.order || 0);
- }).map(function (a) {
- return {
- name: a.name || '',
- type: a.type || 'text',
- alias: a.alias || '',
- order: 0,
- api_url: a.api_url || '',
- api_match_field: a.api_match_field || '',
- api_auto_fill: a.api_auto_fill || '',
- api_format: a.api_format || 'json',
- api_return_type: a.api_return_type || 'text',
- customer_type_id: 0,
- db_match_field: a.db_match_field || '',
- db_auto_fill: a.db_auto_fill || '',
- db_customer_type_id: 0,
- _uid: Math.random().toString(36).slice(2), _imported_by: '',
- };
- });
-
- this.attributes.splice.apply(this.attributes, [index, 1].concat(imported));
- this.renumberOrder();
- },
-
- 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();
- }
- },
-
- getCustomerTypeAttrs(customerTypeId) {
- var ct = this.customerTypes.find(function (c) { return Number(c.id) === Number(customerTypeId); });
- if (!ct || !Array.isArray(ct.attributes)) return [];
- return ct.attributes
- .filter(function (a) { return a.name && a.name.trim(); })
- .slice()
- .sort(function (a, b) { return (a.order || 0) - (b.order || 0); });
- },
-
- onDbCustomerTypeChange(attr) {
- var self = this;
-
- if (!attr._uid) { attr._uid = Math.random().toString(36).slice(2); }
- var uid = attr._uid;
-
- // Remove rows previously imported by this lookup (current session only)
- for (var i = self.attributes.length - 1; i >= 0; i--) {
- if (self.attributes[i]._imported_by === uid) {
- self.attributes.splice(i, 1);
- }
- }
-
- attr.db_match_field = '';
- attr.db_auto_fill = '';
-
- var ct = self.customerTypes.find(function (c) { return Number(c.id) === Number(attr.db_customer_type_id); });
- if (!ct) return;
-
- if (!attr.name.trim()) { attr.name = ct.name; }
-
- var ctAttrs = self.getCustomerTypeAttrs(attr.db_customer_type_id);
- if (ctAttrs.length === 0) return;
-
- // db_match_field and db_auto_fill use attribute NAMES (not aliases)
- // so they work regardless of whether aliases are configured
- attr.db_match_field = ctAttrs[0].name;
- attr.db_auto_fill = ctAttrs.map(function (a) { return a.name; }).join(';');
-
- // Import ALL CT attributes as regular rows after this lookup row
- var lookupIndex = self.attributes.indexOf(attr);
- var rows = ctAttrs.map(function (a) {
- var type = a.type || 'text';
- if (type === 'customer_lookup' || type === 'database_lookup') { type = 'text'; }
- return {
- name: a.name || '', type: type, alias: a.alias || '', order: 0,
- api_url: a.api_url || '', api_match_field: a.api_match_field || '',
- api_auto_fill: a.api_auto_fill || '', api_format: a.api_format || 'json',
- api_return_type: a.api_return_type || 'text',
- customer_type_id: 0, db_match_field: '', db_auto_fill: '', db_customer_type_id: 0,
- _uid: Math.random().toString(36).slice(2), _imported_by: uid,
- };
- });
- self.attributes.splice.apply(self.attributes, [lookupIndex + 1, 0].concat(rows));
- self.renumberOrder();
- },
-
- onAttributeTypeChange(index) {
- var self = this;
- var attr = self.attributes[index];
- if (!attr || attr.type !== 'customer_lookup') return;
- for (var i = self.attributes.length - 1; i >= 0; i--) {
- if (self.attributes[i] !== attr && self.attributes[i].type === 'api_lookup') {
- self.attributes.splice(i, 1);
- }
- }
- self.renumberOrder();
- },
-
- guardSubmit(event) {
- var missing = this.attributes.filter(function (a) {
- return a.type === 'customer_lookup' && !a.name.trim();
- });
- if (missing.length > 0) {
- event.preventDefault();
- alert('One or more Database Lookup attributes are missing a name. Fill in the attribute name field before saving.');
- }
- },
- };
- };
-
- // ── Job ───────────────────────────────────────────────────────────────────────
-
- window.jobTable = function () {
- return {
- table: null,
- isLoading: false,
- errorMessage: '',
-
- init() {
- this.loadTable();
- },
-
- async loadTable() {
- const el = document.getElementById('job-table');
- if (!el || typeof Tabulator === 'undefined' || this.isLoading) {
- return;
- }
-
- this.isLoading = true;
- this.errorMessage = '';
-
- try {
- const response = await fetch('/jobs/data', {
- headers: { Accept: 'application/json' },
- });
-
- if (!response.ok) {
- throw new Error('Unable to load 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.destroy();
- this.table = null;
- }
-
- this.table = new Tabulator(el, {
- data: tableRows,
- layout: 'fitData',
- pagination: true,
- paginationMode: 'local',
- paginationSize: 10,
- paginationSizeSelector: PAGE_SIZES,
- movableColumns: true,
- placeholder: 'No jobs found.',
- initialSort: [{ column: 'job_type_name', dir: 'asc' }],
- columns: columns,
- });
- } catch (error) {
- this.errorMessage = error.message || 'Unable to load 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,
- edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit',
- 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 || '',
- };
-
- 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: 160,
- hozAlign: 'center',
- headerSort: false,
- formatter: function (cell) {
- const url = cell.getValue();
- const id = cell.getRow().getData().id;
- return '<a href="' + _escapeHtml(url) + '" class="button button-secondary button-sm">Edit</a> ' +
- '<button onclick="window.deleteJob(' + id + ')" class="button button-danger button-sm">Delete</button>';
- },
- },
- { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' },
- { title: 'Campaign', field: 'campaign_type_name', minWidth: 160, 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) : '<span class="attr-empty">—</span>';
- },
- });
- });
-
- 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();
- },
- };
- };
-
- 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 || {}),
- apiLookupState: {},
- apiLookupError: {},
- apiLookupOptions: {},
- apiLookupOpen: {},
-
- 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 [];
- var attrs = this.currentType.attributes.slice().sort(function (a, b) {
- return (a.order || 0) - (b.order || 0);
- });
- var hasCustomerLookup = attrs.some(function (a) { return a.type === 'customer_lookup'; });
- if (hasCustomerLookup) {
- attrs = attrs.filter(function (a) { return a.type !== 'api_lookup'; });
- }
- return attrs;
- },
-
- init() {
- var self = this;
- this.currentAttributes.forEach(function (attr) {
- if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
- if (attr.type === 'customer_lookup') { self.fetchDbLookupValue(attr); }
- });
- },
-
- onTypeChange() {
- this.attributeValues = {};
- this.apiLookupOptions = {};
- this.apiLookupState = {};
- this.apiLookupOpen = {};
- var self = this;
- this.$nextTick(function () {
- self.currentAttributes.forEach(function (attr) {
- if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
- if (attr.type === 'customer_lookup') { self.fetchDbLookupValue(attr); }
- });
- });
- },
-
- inputType(attrType) {
- return ['number', 'date'].includes(attrType) ? attrType : 'text';
- },
-
- getApiOptions(name) {
- return this.apiLookupOptions[name] || { fields: [], records: [] };
- },
-
- openApiLookup(name) {
- var o = {}; o[name] = true;
- this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o);
- },
-
- closeApiLookup(name) {
- var o = {}; o[name] = false;
- this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o);
- },
-
- getFilteredRecords(name, search) {
- var records = this.getApiOptions(name).records;
- if (!search) return records;
- var term = String(search).toLowerCase();
- return records.filter(function (rec) {
- return rec._display.some(function (v) {
- return String(v).toLowerCase().indexOf(term) !== -1;
- });
- });
- },
-
- selectApiOption(attr, rec) {
- var newValues = Object.assign({}, this.attributeValues);
- newValues[attr.name] = rec._primary;
-
- if (attr.type === 'customer_lookup') {
- // Auto-fill by attribute NAME: _row is name-keyed, db_auto_fill stores names
- var names = (attr.db_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean);
- names.forEach(function (name) {
- var rowVal = rec._row[name];
- if (rowVal !== undefined && rowVal !== null) {
- newValues[name] = String(rowVal);
- }
- });
- } else {
- // api_lookup: auto-fill by alias
- var autoFill = (attr.api_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean);
- var attrs = this.currentAttributes;
- autoFill.forEach(function (alias) {
- var target = null;
- for (var i = 0; i < attrs.length; i++) {
- if (attrs[i].alias === alias) { target = attrs[i]; break; }
- }
- if (!target) return;
- var rowVal = rec._row[alias];
- if (rowVal !== undefined && rowVal !== null) {
- newValues[target.name] = String(rowVal);
- }
- });
- }
-
- this.attributeValues = newValues;
- this.closeApiLookup(attr.name);
- },
-
- fetchApiValue(attr) {
- var self = this;
- if (!attr.api_url) return;
-
- var resolvedUrl = attr.api_url.replace(/\{alias\}/g, encodeURIComponent(attr.alias || ''));
-
- // Reassign whole objects so Alpine sees new references and re-renders nested x-for
- var s = {}; s[attr.name] = 'loading';
- var e = {}; e[attr.name] = '';
- var o = {}; o[attr.name] = { fields: [], records: [] };
- self.apiLookupState = Object.assign({}, self.apiLookupState, s);
- self.apiLookupError = Object.assign({}, self.apiLookupError, e);
- self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, o);
-
- var matchFields = (attr.api_match_field || '')
- .split(';')
- .map(function (s) { return s.trim(); })
- .filter(Boolean);
-
- fetch('/api/proxy?url=' + encodeURIComponent(resolvedUrl))
- .then(function (res) { return res.json(); })
- .then(function (envelope) {
- if (envelope.error) {
- var se = {}; se[attr.name] = envelope.error;
- var ss = {}; ss[attr.name] = 'error';
- self.apiLookupError = Object.assign({}, self.apiLookupError, se);
- self.apiLookupState = Object.assign({}, self.apiLookupState, ss);
- return;
- }
- var body = envelope.body || '';
- var result = { fields: matchFields.slice(), records: [] };
- var seenRows = [];
-
- function addRow(rawRow) {
- if (seenRows.indexOf(rawRow) !== -1) return;
- seenRows.push(rawRow);
- var display = result.fields.map(function (f) {
- var v = rawRow[f]; return (v !== undefined && v !== null) ? String(v) : '';
- });
- result.records.push({ _primary: display[0] || '', _display: display, _row: rawRow });
- }
-
- if (attr.api_format === 'xml') {
- try {
- var doc = new DOMParser().parseFromString(body, 'text/xml');
- if (result.fields.length === 0) { result.fields = [doc.documentElement.tagName]; }
- // Collect sibling-based rows keyed by the first match field element
- var firstField = result.fields[0];
- var els = doc.getElementsByTagName(firstField);
- for (var xi = 0; xi < els.length; xi++) {
- var row = {};
- var par = els[xi].parentNode;
- if (par) {
- for (var xc = 0; xc < par.childNodes.length; xc++) {
- var cn = par.childNodes[xc];
- if (cn.nodeType === 1) { row[cn.tagName] = cn.textContent.trim(); }
- }
- }
- addRow(row);
- }
- } catch (e) { /* leave records empty */ }
- } else {
- try {
- var parsed = JSON.parse(body);
- if (result.fields.length > 0) {
- // Collect unique parent rows that own the first match field
- _deepFindRows(parsed, result.fields[0]).forEach(function (hit) { addRow(hit.row); });
- } else if (Array.isArray(parsed)) {
- result.fields = ['Value'];
- parsed.forEach(function (item) {
- if (typeof item !== 'object') { addRow({ Value: String(item) }); }
- });
- } else if (typeof parsed === 'string' || typeof parsed === 'number' || typeof parsed === 'boolean') {
- result.fields = ['Value'];
- addRow({ Value: String(parsed) });
- }
- } catch (e) { /* leave records empty */ }
- }
-
- var oo = {}; oo[attr.name] = result;
- var os = {}; os[attr.name] = 'idle';
- self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, oo);
- self.apiLookupState = Object.assign({}, self.apiLookupState, os);
- })
- .catch(function (err) {
- console.error('[api-lookup] fetch failed:', err);
- var ce = {}; ce[attr.name] = 'Network error — see browser console.';
- var cs = {}; cs[attr.name] = 'error';
- self.apiLookupError = Object.assign({}, self.apiLookupError, ce);
- self.apiLookupState = Object.assign({}, self.apiLookupState, cs);
- });
- },
-
- fetchDbLookupValue(attr) {
- var self = this;
- var typeId = Number(attr.db_customer_type_id || 0);
- if (!typeId) return;
-
- var matchField = attr.db_match_field || '';
- var url = '/customers/lookup?type_id=' + typeId;
- if (matchField) { url += '&match_field=' + encodeURIComponent(matchField); }
-
- // Feed into the same apiLookup* state so customer_lookup uses the api_lookup UI
- var s = {}; s[attr.name] = 'loading';
- var e = {}; e[attr.name] = '';
- var o = {}; o[attr.name] = { fields: [], records: [] };
- self.apiLookupState = Object.assign({}, self.apiLookupState, s);
- self.apiLookupError = Object.assign({}, self.apiLookupError, e);
- self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, o);
-
- fetch(url, { headers: { Accept: 'application/json' } })
- .then(function (res) { return res.json(); })
- .then(function (data) {
- var oo = {}; oo[attr.name] = { fields: data.fields || [], records: data.records || [] };
- var os = {}; os[attr.name] = 'idle';
- self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, oo);
- self.apiLookupState = Object.assign({}, self.apiLookupState, os);
- })
- .catch(function (err) {
- console.error('[customer-lookup] fetch failed:', err);
- var ce = {}; ce[attr.name] = 'Network error — see browser console.';
- var cs = {}; cs[attr.name] = 'error';
- self.apiLookupError = Object.assign({}, self.apiLookupError, ce);
- self.apiLookupState = Object.assign({}, self.apiLookupState, cs);
- });
- },
-
- confirmDelete(event) {
- if (confirm('Delete this job? This cannot be undone.')) {
- event.target.submit();
- }
- },
- };
- };
-
- // ── Customer Type ─────────────────────────────────────────────────────────────
-
- window.customerTypeTable = function () {
- return {
- table: null,
-
- init() {
- this.initTable();
- },
-
- initTable() {
- const el = document.getElementById('customer-type-table');
- if (!el || typeof Tabulator === 'undefined') {
- return;
- }
-
- this.table = new Tabulator(el, {
- ajaxURL: '/customer-types/data',
- layout: 'fitColumns',
- responsiveLayout: 'collapse',
- pagination: true,
- paginationMode: 'local',
- paginationSize: 10,
- paginationSizeSelector: PAGE_SIZES,
- movableColumns: true,
- placeholder: 'No customer 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 '<a href="/customer-types/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' +
- '<button onclick="window.deleteCustomerType(' + id + ')" class="button button-danger button-sm">Delete</button>';
- },
- },
- { title: 'Name', field: 'name', minWidth: 200 },
- {
- title: 'Attributes',
- field: 'attributes_summary',
- minWidth: 240,
- formatter: function (cell) {
- const v = cell.getValue();
- return v ? '<span class="attr-summary">' + _escapeHtml(v) + '</span>'
- : '<span class="attr-empty">—</span>';
- },
- },
- { 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('/customer-types/data');
- },
- };
- };
-
- window.deleteCustomerType = function (id) {
- if (!confirm('Delete this customer type? This cannot be undone.')) {
- return;
- }
- _postDelete('/customer-types/' + id + '/delete');
- };
-
- window.customerTypeForm = function (initialAttributes) {
- return {
- attributes: Array.isArray(initialAttributes) ? initialAttributes : [],
- dragIndex: null,
- dragOverIndex: null,
-
- addAttribute() {
- this.attributes.push({ name: '', type: 'text', alias: '', order: this.attributes.length + 1, api_url: '', api_match_field: '', api_auto_fill: '', api_format: 'json', api_return_type: 'text', db_match_field: '', db_auto_fill: '' });
- },
-
- 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 customer type? This cannot be undone.')) {
- event.target.submit();
- }
- },
- };
- };
-
- // ── Customer ──────────────────────────────────────────────────────────────────
-
- window.customerTable = function () {
- return {
- table: null,
- isLoading: false,
- errorMessage: '',
-
- init() {
- this.loadTable();
- },
-
- async loadTable() {
- const el = document.getElementById('customer-table');
- if (!el || typeof Tabulator === 'undefined' || this.isLoading) {
- return;
- }
-
- this.isLoading = true;
- this.errorMessage = '';
-
- try {
- const response = await fetch('/customers/data', {
- headers: { Accept: 'application/json' },
- });
-
- if (!response.ok) {
- throw new Error('Unable to load customers.');
- }
-
- const rows = await response.json();
- const customerRows = Array.isArray(rows) ? rows : [];
- const attributes = this.attributeColumnsForRows(customerRows);
- const tableRows = this.formatRows(customerRows, attributes);
- const columns = this.columnsForAttributes(attributes);
-
- if (this.table) {
- this.table.destroy();
- this.table = null;
- }
-
- this.table = new Tabulator(el, {
- data: tableRows,
- layout: 'fitData',
- pagination: true,
- paginationMode: 'local',
- paginationSize: 10,
- paginationSizeSelector: PAGE_SIZES,
- movableColumns: true,
- placeholder: 'No customers found.',
- initialSort: [{ column: 'customer_type_name', dir: 'asc' }],
- columns: columns,
- });
- } catch (error) {
- this.errorMessage = error.message || 'Unable to load customers.';
- } finally {
- this.isLoading = false;
- }
- },
-
- attributeColumnsForRows(rows) {
- const attributes = [];
-
- rows.forEach((row) => {
- this.normalizeAttributes(row.customer_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,
- edit_url: '/customers/' + encodeURIComponent(row.id) + '/edit',
- customer_type_id: row.customer_type_id || '',
- customer_type_name: row.customer_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: 'edit_url',
- width: 160,
- hozAlign: 'center',
- headerSort: false,
- formatter: function (cell) {
- const url = cell.getValue();
- const id = cell.getRow().getData().id;
- return '<a href="' + _escapeHtml(url) + '" class="button button-secondary button-sm">Edit</a> ' +
- '<button onclick="window.deleteCustomer(' + id + ')" class="button button-danger button-sm">Delete</button>';
- },
- },
- { title: 'Customer ID', field: 'id', width: 110, hozAlign: 'center', headerFilter: 'input' },
- { title: 'Customer Type', field: 'customer_type_name', minWidth: 180, 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) : '<span class="attr-empty">—</span>';
- },
- });
- });
-
- columns.push(
- { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' }
- );
-
- return columns;
- },
-
- reloadTable() {
- this.loadTable();
- },
- };
- };
-
- window.deleteCustomer = function (id) {
- if (!confirm('Delete this customer? This cannot be undone.')) {
- return;
- }
- _postDelete('/customers/' + id + '/delete');
- };
-
- window.customerForm = function (customerTypes, initialTypeId, initialValues) {
- return {
- customerTypes: customerTypes,
- selectedTypeId: String(initialTypeId || ''),
- attributeValues: Object.assign({}, initialValues || {}),
- apiLookupState: {},
- apiLookupError: {},
- apiLookupOptions: {},
- apiLookupOpen: {},
-
- get currentType() {
- var id = this.selectedTypeId;
- if (!id) return null;
- return this.customerTypes.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);
- });
- },
-
- init() {
- var self = this;
- this.currentAttributes.forEach(function (attr) {
- if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
- });
- },
-
- onTypeChange() {
- this.attributeValues = {};
- this.apiLookupOptions = {};
- this.apiLookupState = {};
- this.apiLookupOpen = {};
- var self = this;
- this.$nextTick(function () {
- self.currentAttributes.forEach(function (attr) {
- if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
- });
- });
- },
-
- inputType(attrType) {
- return ['number', 'date'].includes(attrType) ? attrType : 'text';
- },
-
- getApiOptions(name) {
- return this.apiLookupOptions[name] || { fields: [], records: [] };
- },
-
- openApiLookup(name) {
- var o = {}; o[name] = true;
- this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o);
- },
-
- closeApiLookup(name) {
- var o = {}; o[name] = false;
- this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o);
- },
-
- getFilteredRecords(name, search) {
- var records = this.getApiOptions(name).records;
- if (!search) return records;
- var term = String(search).toLowerCase();
- return records.filter(function (rec) {
- return rec._display.some(function (v) {
- return String(v).toLowerCase().indexOf(term) !== -1;
- });
- });
- },
-
- selectApiOption(attr, rec) {
- var newValues = Object.assign({}, this.attributeValues);
- newValues[attr.name] = rec._primary;
-
- if (attr.type === 'customer_lookup') {
- // Auto-fill by attribute NAME: _row is name-keyed, db_auto_fill stores names
- var names = (attr.db_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean);
- names.forEach(function (name) {
- var rowVal = rec._row[name];
- if (rowVal !== undefined && rowVal !== null) {
- newValues[name] = String(rowVal);
- }
- });
- } else {
- // api_lookup: auto-fill by alias
- var autoFill = (attr.api_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean);
- var attrs = this.currentAttributes;
- autoFill.forEach(function (alias) {
- var target = null;
- for (var i = 0; i < attrs.length; i++) {
- if (attrs[i].alias === alias) { target = attrs[i]; break; }
- }
- if (!target) return;
- var rowVal = rec._row[alias];
- if (rowVal !== undefined && rowVal !== null) {
- newValues[target.name] = String(rowVal);
- }
- });
- }
-
- this.attributeValues = newValues;
- this.closeApiLookup(attr.name);
- },
-
- fetchApiValue(attr) {
- var self = this;
- if (!attr.api_url) return;
-
- var resolvedUrl = attr.api_url.replace(/\{alias\}/g, encodeURIComponent(attr.alias || ''));
-
- var s = {}; s[attr.name] = 'loading';
- var e = {}; e[attr.name] = '';
- var o = {}; o[attr.name] = { fields: [], records: [] };
- self.apiLookupState = Object.assign({}, self.apiLookupState, s);
- self.apiLookupError = Object.assign({}, self.apiLookupError, e);
- self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, o);
-
- var matchFields = (attr.api_match_field || '')
- .split(';')
- .map(function (s) { return s.trim(); })
- .filter(Boolean);
-
- fetch('/api/proxy?url=' + encodeURIComponent(resolvedUrl))
- .then(function (res) { return res.json(); })
- .then(function (envelope) {
- if (envelope.error) {
- var se = {}; se[attr.name] = envelope.error;
- var ss = {}; ss[attr.name] = 'error';
- self.apiLookupError = Object.assign({}, self.apiLookupError, se);
- self.apiLookupState = Object.assign({}, self.apiLookupState, ss);
- return;
- }
- var body = envelope.body || '';
- var result = { fields: matchFields.slice(), records: [] };
- var seenRows = [];
-
- function addRow(rawRow) {
- if (seenRows.indexOf(rawRow) !== -1) return;
- seenRows.push(rawRow);
- var display = result.fields.map(function (f) {
- var v = rawRow[f]; return (v !== undefined && v !== null) ? String(v) : '';
- });
- result.records.push({ _primary: display[0] || '', _display: display, _row: rawRow });
- }
-
- if (attr.api_format === 'xml') {
- try {
- var doc = new DOMParser().parseFromString(body, 'text/xml');
- if (result.fields.length === 0) { result.fields = [doc.documentElement.tagName]; }
- var firstField = result.fields[0];
- var els = doc.getElementsByTagName(firstField);
- for (var xi = 0; xi < els.length; xi++) {
- var row = {};
- var par = els[xi].parentNode;
- if (par) {
- for (var xc = 0; xc < par.childNodes.length; xc++) {
- var cn = par.childNodes[xc];
- if (cn.nodeType === 1) { row[cn.tagName] = cn.textContent.trim(); }
- }
- }
- addRow(row);
- }
- } catch (e) { /* leave records empty */ }
- } else {
- try {
- var parsed = JSON.parse(body);
- if (result.fields.length > 0) {
- _deepFindRows(parsed, result.fields[0]).forEach(function (hit) { addRow(hit.row); });
- } else if (Array.isArray(parsed)) {
- result.fields = ['Value'];
- parsed.forEach(function (item) {
- if (typeof item !== 'object') { addRow({ Value: String(item) }); }
- });
- } else if (typeof parsed === 'string' || typeof parsed === 'number' || typeof parsed === 'boolean') {
- result.fields = ['Value'];
- addRow({ Value: String(parsed) });
- }
- } catch (e) { /* leave records empty */ }
- }
-
- var oo = {}; oo[attr.name] = result;
- var os = {}; os[attr.name] = 'idle';
- self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, oo);
- self.apiLookupState = Object.assign({}, self.apiLookupState, os);
- })
- .catch(function (err) {
- console.error('[api-lookup] fetch failed:', err);
- var ce = {}; ce[attr.name] = 'Network error — see browser console.';
- var cs = {}; cs[attr.name] = 'error';
- self.apiLookupError = Object.assign({}, self.apiLookupError, ce);
- self.apiLookupState = Object.assign({}, self.apiLookupState, cs);
- });
- },
-
- confirmDelete(event) {
- if (confirm('Delete this customer? This cannot be undone.')) {
- event.target.submit();
- }
- },
- };
- };
-
- // Unsaved-changes guard — fires beforeunload warning when a .ct-form has been
- // touched but not yet submitted. Delete forms and the logout form are excluded
- // because they use different CSS classes and are intentional navigation.
- (function () {
- function initDirtyFormGuard() {
- var forms = document.querySelectorAll('form.ct-form');
- if (!forms.length) return;
-
- var dirty = false;
-
- function markDirty() { dirty = true; }
- function markClean() { dirty = false; }
-
- forms.forEach(function (form) {
- form.addEventListener('input', markDirty);
- form.addEventListener('change', markDirty);
- form.addEventListener('submit', markClean);
- });
-
- window.addEventListener('beforeunload', function (e) {
- if (!dirty) return;
- e.preventDefault();
- e.returnValue = '';
- });
- }
-
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', initDirtyFormGuard);
- } else {
- initDirtyFormGuard();
- }
- }());
|