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