|
- // ── 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 '<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) {
- 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 '<a href="/jobs/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' +
- '<button onclick="window.deleteJob(' + id + ')" class="button button-danger button-sm">Delete</button>';
- },
- },
- { 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 ? '<span class="attr-summary">' + _escapeHtml(v) + '</span>'
- : '<span class="attr-empty">—</span>';
- },
- },
- { 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();
- }
- },
- };
- };
|