Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

1627 рядки
59KB

  1. // ── Shared util ───────────────────────────────────────────────────────────────
  2. const PAGE_SIZES = [10, 25, 50, 100];
  3. const PAGE_SIZES_SM = [5, 10, 25, 50];
  4. function _postDelete(action) {
  5. const form = document.createElement('form');
  6. form.method = 'POST';
  7. form.action = action;
  8. const t = document.createElement('input');
  9. t.type = 'hidden';
  10. t.name = '_token';
  11. t.value = window.__csrf || '';
  12. form.appendChild(t);
  13. document.body.appendChild(form);
  14. form.submit();
  15. }
  16. // Returns every {value, row} pair where `key` has a primitive value anywhere in the tree.
  17. // `row` is the nearest plain-object ancestor that directly owns `key`.
  18. function _deepFindRows(obj, key, out) {
  19. out = out || [];
  20. if (obj === null || typeof obj !== 'object') return out;
  21. if (Array.isArray(obj)) {
  22. for (var i = 0; i < obj.length; i++) { _deepFindRows(obj[i], key, out); }
  23. return out;
  24. }
  25. if (Object.prototype.hasOwnProperty.call(obj, key)) {
  26. var v = obj[key];
  27. if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
  28. out.push({ value: String(v), row: obj });
  29. }
  30. }
  31. for (var k in obj) {
  32. if (Object.prototype.hasOwnProperty.call(obj, k)) { _deepFindRows(obj[k], key, out); }
  33. }
  34. return out;}
  35. function _escapeHtml(value) {
  36. return String(value).replace(/[&<>"']/g, function (char) {
  37. return {
  38. '&': '&amp;',
  39. '<': '&lt;',
  40. '>': '&gt;',
  41. '"': '&quot;',
  42. "'": '&#039;',
  43. }[char];
  44. });
  45. }
  46. // ── Campaign Type ─────────────────────────────────────────────────────────────
  47. window.campaignTypeTable = function () {
  48. return {
  49. table: null,
  50. init() {
  51. this.initTable();
  52. },
  53. initTable() {
  54. const el = document.getElementById('campaign-type-table');
  55. if (!el || typeof Tabulator === 'undefined') {
  56. return;
  57. }
  58. this.table = new Tabulator(el, {
  59. ajaxURL: '/campaign-types/data',
  60. layout: 'fitColumns',
  61. responsiveLayout: 'collapse',
  62. pagination: true,
  63. paginationMode: 'local',
  64. paginationSize: 10,
  65. paginationSizeSelector: PAGE_SIZES,
  66. movableColumns: true,
  67. placeholder: 'No campaign types found.',
  68. initialSort: [{ column: 'name', dir: 'asc' }],
  69. columns: [
  70. {
  71. title: 'Actions',
  72. field: 'id',
  73. width: 160,
  74. hozAlign: 'center',
  75. headerSort: false,
  76. formatter: function (cell) {
  77. const id = cell.getValue();
  78. return '<a href="/campaign-types/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' +
  79. '<button onclick="window.deleteCampaignType(' + id + ')" class="button button-danger button-sm">Delete</button>';
  80. },
  81. },
  82. { title: 'Name', field: 'name', minWidth: 200 },
  83. {
  84. title: 'Attributes',
  85. field: 'attributes_summary',
  86. minWidth: 240,
  87. formatter: function (cell) {
  88. const v = cell.getValue();
  89. return v ? '<span class="attr-summary">' + _escapeHtml(v) + '</span>'
  90. : '<span class="attr-empty">&mdash;</span>';
  91. },
  92. },
  93. { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 },
  94. { title: 'Created', field: 'created_at', minWidth: 160 },
  95. ],
  96. });
  97. },
  98. reloadTable() {
  99. if (!this.table) {
  100. this.initTable();
  101. return;
  102. }
  103. this.table.setData('/campaign-types/data');
  104. },
  105. };
  106. };
  107. window.deleteCampaignType = function (id) {
  108. if (!confirm('Delete this campaign type? This cannot be undone.')) {
  109. return;
  110. }
  111. _postDelete('/campaign-types/' + id + '/delete');
  112. };
  113. window.campaignTypeForm = function (initialAttributes) {
  114. return {
  115. attributes: Array.isArray(initialAttributes) ? initialAttributes : [],
  116. dragIndex: null,
  117. dragOverIndex: null,
  118. addAttribute() {
  119. this.attributes.push({ name: '', type: 'text', order: this.attributes.length + 1 });
  120. },
  121. removeAttribute(index) {
  122. this.attributes.splice(index, 1);
  123. this.renumberOrder();
  124. },
  125. renumberOrder() {
  126. this.attributes.forEach(function (attr, i) { attr.order = i + 1; });
  127. },
  128. dragStart(event, index) {
  129. this.dragIndex = index;
  130. event.dataTransfer.effectAllowed = 'move';
  131. },
  132. dragOver(event, index) {
  133. this.dragOverIndex = index;
  134. },
  135. drop(event, index) {
  136. if (this.dragIndex !== null && this.dragIndex !== index) {
  137. var moved = this.attributes.splice(this.dragIndex, 1)[0];
  138. this.attributes.splice(index, 0, moved);
  139. this.renumberOrder();
  140. }
  141. this.dragIndex = null;
  142. this.dragOverIndex = null;
  143. },
  144. dragEnd() {
  145. this.dragIndex = null;
  146. this.dragOverIndex = null;
  147. },
  148. confirmDelete(event) {
  149. if (confirm('Delete this campaign type? This cannot be undone.')) {
  150. event.target.submit();
  151. }
  152. },
  153. };
  154. };
  155. // ── Campaign ──────────────────────────────────────────────────────────────────
  156. window.campaignTable = function () {
  157. return {
  158. table: null,
  159. jobsTable: null,
  160. isLoading: false,
  161. isJobsLoading: false,
  162. errorMessage: '',
  163. jobsErrorMessage: '',
  164. selectedCampaignId: null,
  165. selectedCampaignTitle: '',
  166. init() {
  167. this.loadTable();
  168. },
  169. async loadTable() {
  170. const el = document.getElementById('campaign-table');
  171. if (!el || typeof Tabulator === 'undefined' || this.isLoading) {
  172. return;
  173. }
  174. this.isLoading = true;
  175. this.errorMessage = '';
  176. try {
  177. const response = await fetch('/campaigns/data', {
  178. headers: { Accept: 'application/json' },
  179. });
  180. if (!response.ok) {
  181. throw new Error('Unable to load campaigns.');
  182. }
  183. const rows = await response.json();
  184. const campaignRows = Array.isArray(rows) ? rows : [];
  185. const attributes = this.attributeColumnsForRows(campaignRows);
  186. const tableRows = this.formatRows(campaignRows, attributes);
  187. const columns = this.columnsForAttributes(attributes);
  188. if (!this.table) {
  189. this.table = new Tabulator(el, {
  190. data: tableRows,
  191. layout: 'fitColumns',
  192. responsiveLayout: 'collapse',
  193. pagination: true,
  194. paginationMode: 'local',
  195. paginationSize: 10,
  196. paginationSizeSelector: PAGE_SIZES,
  197. movableColumns: true,
  198. placeholder: 'No campaigns found.',
  199. initialSort: [{ column: 'campaign_type_name', dir: 'asc' }],
  200. columns: columns,
  201. });
  202. this.table.on('rowClick', (event, row) => this.goToCampaignJobs(event, row));
  203. } else {
  204. this.table.setColumns(columns);
  205. this.table.setData(tableRows);
  206. }
  207. } catch (error) {
  208. this.errorMessage = error.message || 'Unable to load campaigns.';
  209. } finally {
  210. this.isLoading = false;
  211. }
  212. },
  213. attributeColumnsForRows(rows) {
  214. const attributes = [];
  215. rows.forEach((row) => {
  216. this.normalizeAttributes(row.campaign_type_attributes || []).forEach((attr) => {
  217. if (!attributes.some((existing) => existing.name === attr.name)) {
  218. attributes.push(attr);
  219. }
  220. });
  221. Object.keys(row.attribute_values || {}).forEach((name) => {
  222. if (!attributes.some((existing) => existing.name === name)) {
  223. attributes.push({ name: name, type: 'text', order: attributes.length + 1 });
  224. }
  225. });
  226. });
  227. return attributes;
  228. },
  229. normalizeAttributes(attributes) {
  230. return attributes
  231. .filter((attr) => attr && attr.name)
  232. .slice()
  233. .sort((a, b) => (a.order || 0) - (b.order || 0));
  234. },
  235. formatRows(rows, attributes) {
  236. return rows.map((row) => {
  237. const attributeValues = row.attribute_values || {};
  238. const tableRow = {
  239. id: row.id,
  240. campaign_type_name: row.campaign_type_name || '',
  241. created_at: row.created_at || '',
  242. };
  243. attributes.forEach((attr, index) => {
  244. tableRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? '');
  245. });
  246. return tableRow;
  247. });
  248. },
  249. formatAttributeValue(value) {
  250. if (value === null || value === undefined) {
  251. return '';
  252. }
  253. if (Array.isArray(value) || typeof value === 'object') {
  254. return JSON.stringify(value);
  255. }
  256. return String(value);
  257. },
  258. columnsForAttributes(attributes) {
  259. const columns = [
  260. {
  261. title: 'Actions',
  262. field: 'id',
  263. width: 230,
  264. hozAlign: 'center',
  265. headerSort: false,
  266. formatter: function (cell) {
  267. const id = cell.getValue();
  268. return '<a href="/campaigns/' + id + '/jobs" class="button button-primary button-sm">Jobs</a> ' +
  269. '<a href="/campaigns/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' +
  270. '<button onclick="window.deleteCampaign(' + id + ')" class="button button-danger button-sm">Delete</button>';
  271. },
  272. },
  273. {
  274. title: 'Campaign Type',
  275. field: 'campaign_type_name',
  276. minWidth: 160,
  277. headerFilter: 'input',
  278. },
  279. ];
  280. attributes.forEach((attr, index) => {
  281. columns.push({
  282. title: attr.name,
  283. field: 'attr_' + index,
  284. minWidth: 150,
  285. headerFilter: 'input',
  286. formatter: function (cell) {
  287. const value = cell.getValue();
  288. return value ? _escapeHtml(value) : '<span class="attr-empty">&mdash;</span>';
  289. },
  290. });
  291. });
  292. columns.push(
  293. { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' }
  294. );
  295. return columns;
  296. },
  297. goToCampaignJobs(event, row) {
  298. const target = event.target;
  299. if (target instanceof Element && target.closest('a, button')) {
  300. return;
  301. }
  302. window.location.href = '/campaigns/' + encodeURIComponent(row.getData().id) + '/jobs';
  303. },
  304. reloadTable() {
  305. this.loadTable();
  306. },
  307. openCampaignJobs(campaign) {
  308. this.selectedCampaignId = campaign.id;
  309. this.selectedCampaignTitle = 'Campaign #' + campaign.id + ' - ' + campaign.campaign_type_name;
  310. this.$nextTick(() => this.loadJobsTable());
  311. },
  312. async reloadJobsTable() {
  313. if (!this.selectedCampaignId) {
  314. return;
  315. }
  316. await this.loadJobsTable();
  317. },
  318. closeJobsTable() {
  319. this.selectedCampaignId = null;
  320. this.selectedCampaignTitle = '';
  321. this.jobsErrorMessage = '';
  322. if (this.jobsTable && typeof this.jobsTable.destroy === 'function') {
  323. this.jobsTable.destroy();
  324. }
  325. this.jobsTable = null;
  326. },
  327. async loadJobsTable() {
  328. const el = document.getElementById('campaign-jobs-drilldown-table');
  329. if (!el || typeof Tabulator === 'undefined' || this.isJobsLoading) {
  330. return;
  331. }
  332. this.isJobsLoading = true;
  333. this.jobsErrorMessage = '';
  334. try {
  335. const response = await fetch('/campaigns/' + encodeURIComponent(this.selectedCampaignId) + '/jobs/data', {
  336. headers: { Accept: 'application/json' },
  337. });
  338. if (!response.ok) {
  339. throw new Error('Unable to load campaign jobs.');
  340. }
  341. const rows = await response.json();
  342. const jobRows = Array.isArray(rows) ? rows : [];
  343. const attributes = this.jobAttributeColumnsForRows(jobRows);
  344. const tableRows = this.formatJobRows(jobRows, attributes);
  345. const columns = this.jobColumnsForAttributes(attributes);
  346. if (!this.jobsTable) {
  347. this.jobsTable = new Tabulator(el, {
  348. data: tableRows,
  349. layout: 'fitColumns',
  350. responsiveLayout: 'collapse',
  351. pagination: true,
  352. paginationMode: 'local',
  353. paginationSize: 10,
  354. paginationSizeSelector: PAGE_SIZES,
  355. movableColumns: true,
  356. placeholder: 'No jobs found for this campaign.',
  357. initialSort: [{ column: 'job_type_name', dir: 'asc' }],
  358. columns: columns,
  359. });
  360. } else {
  361. this.jobsTable.setColumns(columns);
  362. this.jobsTable.setData(tableRows);
  363. }
  364. } catch (error) {
  365. this.jobsErrorMessage = error.message || 'Unable to load campaign jobs.';
  366. } finally {
  367. this.isJobsLoading = false;
  368. }
  369. },
  370. jobAttributeColumnsForRows(rows) {
  371. const attributes = [];
  372. rows.forEach((row) => {
  373. this.normalizeAttributes(row.job_type_attributes || []).forEach((attr) => {
  374. if (!attributes.some((existing) => existing.name === attr.name)) {
  375. attributes.push(attr);
  376. }
  377. });
  378. Object.keys(row.attribute_values || {}).forEach((name) => {
  379. if (!attributes.some((existing) => existing.name === name)) {
  380. attributes.push({ name: name, type: 'text', order: attributes.length + 1 });
  381. }
  382. });
  383. });
  384. return attributes;
  385. },
  386. formatJobRows(rows, attributes) {
  387. return rows.map((row) => {
  388. const attributeValues = row.attribute_values || {};
  389. const tableRow = {
  390. id: row.id,
  391. campaign_id: row.campaign_id || '',
  392. job_type_id: row.job_type_id || '',
  393. job_type_name: row.job_type_name || '',
  394. created_at: row.created_at || '',
  395. updated_at: row.updated_at || '',
  396. edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit',
  397. };
  398. attributes.forEach((attr, index) => {
  399. tableRow['job_attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? '');
  400. });
  401. return tableRow;
  402. });
  403. },
  404. jobColumnsForAttributes(attributes) {
  405. const columns = [
  406. {
  407. title: 'Actions',
  408. field: 'edit_url',
  409. width: 90,
  410. hozAlign: 'center',
  411. headerSort: false,
  412. formatter: function (cell) {
  413. return '<a href="' + _escapeHtml(cell.getValue()) + '" class="button button-secondary button-sm">Edit</a>';
  414. },
  415. },
  416. { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' },
  417. { title: 'Campaign ID', field: 'campaign_id', width: 120, hozAlign: 'center', headerFilter: 'input' },
  418. { title: 'Job Type ID', field: 'job_type_id', width: 120, hozAlign: 'center', headerFilter: 'input' },
  419. { title: 'Job Type', field: 'job_type_name', minWidth: 160, headerFilter: 'input' },
  420. ];
  421. attributes.forEach((attr, index) => {
  422. columns.push({
  423. title: attr.name,
  424. field: 'job_attr_' + index,
  425. minWidth: 150,
  426. headerFilter: 'input',
  427. formatter: function (cell) {
  428. const value = cell.getValue();
  429. return value ? _escapeHtml(value) : '<span class="attr-empty">&mdash;</span>';
  430. },
  431. });
  432. });
  433. columns.push(
  434. { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' },
  435. { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' }
  436. );
  437. return columns;
  438. },
  439. };
  440. };
  441. window.deleteCampaign = function (id) {
  442. if (!confirm('Delete this campaign? This cannot be undone.')) {
  443. return;
  444. }
  445. _postDelete('/campaigns/' + id + '/delete');
  446. };
  447. window.campaignJobsPageTable = function (campaignId, jobTypes) {
  448. return {
  449. table: null,
  450. jobTypes: Array.isArray(jobTypes) ? jobTypes : [],
  451. isLoading: false,
  452. isConnecting: false,
  453. isImporting: false,
  454. errorMessage: '',
  455. importSheetUrl: '',
  456. sheets: [],
  457. selectedSheetGid: '',
  458. selectedImportJobTypeId: '0',
  459. importMessage: '',
  460. importErrorMessage: '',
  461. // File upload state
  462. importSource: 'sheets',
  463. fileSelected: false,
  464. fileTempName: '',
  465. fileSheets: [],
  466. selectedFileSheetGid: '',
  467. selectedFileJobTypeId: '0',
  468. isLoadingFile: false,
  469. isImportingFile: false,
  470. init() {
  471. this.loadTable();
  472. },
  473. dataUrl() {
  474. return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/data';
  475. },
  476. sheetsUrl() {
  477. return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import/sheets';
  478. },
  479. importUrl() {
  480. return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import';
  481. },
  482. async loadTable() {
  483. const el = document.getElementById('campaign-jobs-page-table');
  484. if (!el || typeof Tabulator === 'undefined' || this.isLoading) {
  485. return;
  486. }
  487. this.isLoading = true;
  488. this.errorMessage = '';
  489. try {
  490. const response = await fetch(this.dataUrl(), {
  491. headers: { Accept: 'application/json' },
  492. });
  493. if (!response.ok) {
  494. throw new Error('Unable to load campaign jobs.');
  495. }
  496. const rows = await response.json();
  497. const jobRows = Array.isArray(rows) ? rows : [];
  498. const attributes = this.attributeColumnsForRows(jobRows);
  499. const tableRows = this.formatRows(jobRows, attributes);
  500. const columns = this.columnsForAttributes(attributes);
  501. if (!this.table) {
  502. this.table = new Tabulator(el, {
  503. data: tableRows,
  504. layout: 'fitData',
  505. maxHeight: '65vh',
  506. pagination: true,
  507. paginationMode: 'local',
  508. paginationSize: 10,
  509. paginationSizeSelector: PAGE_SIZES,
  510. movableColumns: true,
  511. placeholder: 'No jobs found for this campaign.',
  512. initialSort: [{ column: 'job_type_name', dir: 'asc' }],
  513. columns: columns,
  514. });
  515. } else {
  516. this.table.setColumns(columns);
  517. this.table.setData(tableRows);
  518. }
  519. } catch (error) {
  520. this.errorMessage = error.message || 'Unable to load campaign jobs.';
  521. } finally {
  522. this.isLoading = false;
  523. }
  524. },
  525. attributeColumnsForRows(rows) {
  526. const attributes = [];
  527. rows.forEach((row) => {
  528. this.normalizeAttributes(row.job_type_attributes || []).forEach((attr) => {
  529. if (!attributes.some((existing) => existing.name === attr.name)) {
  530. attributes.push(attr);
  531. }
  532. });
  533. Object.keys(row.attribute_values || {}).forEach((name) => {
  534. if (!attributes.some((existing) => existing.name === name)) {
  535. attributes.push({ name: name, type: 'text', order: attributes.length + 1 });
  536. }
  537. });
  538. });
  539. return attributes;
  540. },
  541. normalizeAttributes(attributes) {
  542. return attributes
  543. .filter((attr) => attr && attr.name)
  544. .slice()
  545. .sort((a, b) => (a.order || 0) - (b.order || 0));
  546. },
  547. formatRows(rows, attributes) {
  548. return rows.map((row) => {
  549. const attributeValues = row.attribute_values || {};
  550. const tableRow = {
  551. id: row.id,
  552. campaign_id: row.campaign_id || '',
  553. campaign_type_name: row.campaign_type_name || '',
  554. job_type_id: row.job_type_id || '',
  555. job_type_name: row.job_type_name || '',
  556. created_at: row.created_at || '',
  557. updated_at: row.updated_at || '',
  558. edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit',
  559. };
  560. attributes.forEach((attr, index) => {
  561. tableRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? '');
  562. });
  563. return tableRow;
  564. });
  565. },
  566. formatAttributeValue(value) {
  567. if (value === null || value === undefined) {
  568. return '';
  569. }
  570. if (Array.isArray(value) || typeof value === 'object') {
  571. return JSON.stringify(value);
  572. }
  573. return String(value);
  574. },
  575. columnsForAttributes(attributes) {
  576. const columns = [
  577. {
  578. title: 'Actions',
  579. field: 'edit_url',
  580. width: 90,
  581. hozAlign: 'center',
  582. headerSort: false,
  583. formatter: function (cell) {
  584. return '<a href="' + _escapeHtml(cell.getValue()) + '" class="button button-secondary button-sm">Edit</a>';
  585. },
  586. },
  587. { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' },
  588. { title: 'Campaign ID', field: 'campaign_id', width: 120, hozAlign: 'center', headerFilter: 'input' },
  589. { title: 'Campaign Type', field: 'campaign_type_name', minWidth: 160, headerFilter: 'input' },
  590. { title: 'Job Type ID', field: 'job_type_id', width: 120, hozAlign: 'center', headerFilter: 'input' },
  591. { title: 'Job Type', field: 'job_type_name', minWidth: 160, headerFilter: 'input' },
  592. ];
  593. attributes.forEach((attr, index) => {
  594. columns.push({
  595. title: attr.name,
  596. field: 'attr_' + index,
  597. minWidth: 150,
  598. headerFilter: 'input',
  599. formatter: function (cell) {
  600. const value = cell.getValue();
  601. return value ? _escapeHtml(value) : '<span class="attr-empty">&mdash;</span>';
  602. },
  603. });
  604. });
  605. columns.push(
  606. { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' },
  607. { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' }
  608. );
  609. return columns;
  610. },
  611. reloadTable() {
  612. this.loadTable();
  613. },
  614. async connectGoogleSheet() {
  615. this.isConnecting = true;
  616. this.importMessage = '';
  617. this.importErrorMessage = '';
  618. this.sheets = [];
  619. this.selectedSheetGid = '';
  620. try {
  621. const data = await this.postImportForm(this.sheetsUrl(), {
  622. sheet_url: this.importSheetUrl,
  623. });
  624. this.sheets = Array.isArray(data.sheets) ? data.sheets : [];
  625. if (this.sheets.length > 0) {
  626. this.selectedSheetGid = this.sheets[0].gid;
  627. this.importMessage = 'Connected. Select a sheet and job type to import.';
  628. } else {
  629. this.importErrorMessage = 'No sheets were found in that Google Sheets file.';
  630. }
  631. } catch (error) {
  632. this.importErrorMessage = error.message || 'Unable to connect to Google Sheets.';
  633. } finally {
  634. this.isConnecting = false;
  635. }
  636. },
  637. async importGoogleSheet() {
  638. this.isImporting = true;
  639. this.importMessage = '';
  640. this.importErrorMessage = '';
  641. try {
  642. const data = await this.postImportForm(this.importUrl(), {
  643. sheet_url: this.importSheetUrl,
  644. sheet_gid: this.selectedSheetGid,
  645. job_type_id: this.selectedImportJobTypeId,
  646. });
  647. const matched = Array.isArray(data.matched_attributes) ? data.matched_attributes.join(', ') : '';
  648. this.importMessage = 'Imported ' + data.imported + ' jobs. Skipped ' + data.skipped + ' empty rows.' +
  649. (matched ? ' Matched: ' + matched + '.' : '');
  650. await this.loadTable();
  651. } catch (error) {
  652. this.importErrorMessage = error.message || 'Unable to import Google Sheet.';
  653. } finally {
  654. this.isImporting = false;
  655. }
  656. },
  657. async postImportForm(url, fields) {
  658. const body = new URLSearchParams();
  659. body.set('_token', window.__csrf || '');
  660. Object.keys(fields).forEach((key) => {
  661. body.set(key, fields[key] || '');
  662. });
  663. const response = await fetch(url, {
  664. method: 'POST',
  665. headers: {
  666. Accept: 'application/json',
  667. 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
  668. },
  669. body: body.toString(),
  670. });
  671. const data = await response.json().catch(() => ({}));
  672. if (!response.ok) {
  673. throw new Error(data.error || 'Import failed.');
  674. }
  675. return data;
  676. },
  677. // ── File upload methods ───────────────────────────────────────────────
  678. onFileSelect(event) {
  679. this.fileSelected = event.target.files && event.target.files.length > 0;
  680. this.fileTempName = '';
  681. this.fileSheets = [];
  682. this.selectedFileSheetGid = '';
  683. this.importMessage = '';
  684. this.importErrorMessage = '';
  685. },
  686. async loadFileSheets() {
  687. this.isLoadingFile = true;
  688. this.importMessage = '';
  689. this.importErrorMessage = '';
  690. this.fileTempName = '';
  691. this.fileSheets = [];
  692. this.selectedFileSheetGid = '';
  693. try {
  694. const fileInput = this.$refs.fileInput;
  695. if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
  696. throw new Error('No file selected.');
  697. }
  698. const form = new FormData();
  699. form.set('_token', window.__csrf || '');
  700. form.set('import_file', fileInput.files[0]);
  701. const url = '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import/file/sheets';
  702. const response = await fetch(url, {
  703. method: 'POST',
  704. headers: { Accept: 'application/json' },
  705. body: form,
  706. });
  707. const data = await response.json().catch(() => ({}));
  708. if (!response.ok) {
  709. throw new Error(data.error || 'Could not read the file.');
  710. }
  711. this.fileTempName = data.temp_file || '';
  712. this.fileSheets = Array.isArray(data.sheets) ? data.sheets : [];
  713. if (this.fileSheets.length > 0) {
  714. this.selectedFileSheetGid = this.fileSheets[0].gid;
  715. this.importMessage = 'File loaded. Select a sheet and job type to import.';
  716. } else {
  717. this.importErrorMessage = 'No sheets were found in the uploaded file.';
  718. }
  719. } catch (error) {
  720. this.importErrorMessage = error.message || 'Could not read the file.';
  721. } finally {
  722. this.isLoadingFile = false;
  723. }
  724. },
  725. async importFile() {
  726. this.isImportingFile = true;
  727. this.importMessage = '';
  728. this.importErrorMessage = '';
  729. try {
  730. const url = '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import/file';
  731. const data = await this.postImportForm(url, {
  732. temp_file: this.fileTempName,
  733. sheet_gid: this.selectedFileSheetGid,
  734. job_type_id: this.selectedFileJobTypeId,
  735. });
  736. const matched = Array.isArray(data.matched_attributes) ? data.matched_attributes.join(', ') : '';
  737. this.importMessage = 'Imported ' + data.imported + ' jobs. Skipped ' + data.skipped + ' empty rows.' +
  738. (matched ? ' Matched: ' + matched + '.' : '');
  739. this.fileTempName = '';
  740. await this.loadTable();
  741. } catch (error) {
  742. this.importErrorMessage = error.message || 'Import failed.';
  743. } finally {
  744. this.isImportingFile = false;
  745. }
  746. },
  747. };
  748. };
  749. window.campaignForm = function (types, initialTypeId, initialValues) {
  750. return {
  751. types: types,
  752. selectedTypeId: String(initialTypeId || ''),
  753. attributeValues: Object.assign({}, initialValues || {}),
  754. get currentType() {
  755. var id = this.selectedTypeId;
  756. if (!id) return null;
  757. return this.types.find(function (t) { return String(t.id) === String(id); }) || null;
  758. },
  759. get currentAttributes() {
  760. if (!this.currentType) return [];
  761. return this.currentType.attributes.slice().sort(function (a, b) {
  762. return (a.order || 0) - (b.order || 0);
  763. });
  764. },
  765. onTypeChange() {
  766. this.attributeValues = {};
  767. },
  768. inputType(attrType) {
  769. return ['number', 'date'].includes(attrType) ? attrType : 'text';
  770. },
  771. confirmDelete(event) {
  772. if (confirm('Delete this campaign? This cannot be undone.')) {
  773. event.target.submit();
  774. }
  775. },
  776. };
  777. };
  778. // Campaign Jobs
  779. window.campaignJobsTable = function (campaignId) {
  780. return {
  781. tables: {},
  782. groups: [],
  783. isVisible: false,
  784. isLoading: false,
  785. hasLoaded: false,
  786. errorMessage: '',
  787. dataUrl() {
  788. return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/data';
  789. },
  790. async showTable() {
  791. this.isVisible = true;
  792. if (!this.hasLoaded) {
  793. await this.loadGroups();
  794. }
  795. },
  796. hideTable() {
  797. this.isVisible = false;
  798. },
  799. async reloadTable() {
  800. this.isVisible = true;
  801. await this.loadGroups();
  802. },
  803. async loadGroups() {
  804. this.isLoading = true;
  805. this.errorMessage = '';
  806. this.destroyTables();
  807. try {
  808. const response = await fetch(this.dataUrl(), {
  809. headers: { Accept: 'application/json' },
  810. });
  811. if (!response.ok) {
  812. throw new Error('Unable to load campaign jobs.');
  813. }
  814. const rows = await response.json();
  815. this.groups = this.groupRows(Array.isArray(rows) ? rows : []);
  816. this.hasLoaded = true;
  817. this.$nextTick(() => this.initTables());
  818. } catch (error) {
  819. this.groups = [];
  820. this.errorMessage = error.message || 'Unable to load campaign jobs.';
  821. } finally {
  822. this.isLoading = false;
  823. }
  824. },
  825. groupRows(rows) {
  826. const groups = {};
  827. rows.forEach((row) => {
  828. const id = String(row.job_type_id || 0);
  829. if (!groups[id]) {
  830. groups[id] = {
  831. id: id,
  832. elementId: 'campaign-jobs-table-' + id,
  833. name: row.job_type_name || 'Job Type #' + id,
  834. attributes: this.normalizeAttributes(row.job_type_attributes || []),
  835. rows: [],
  836. };
  837. }
  838. const attributeValues = row.attribute_values || {};
  839. this.ensureAttributeColumns(groups[id], attributeValues);
  840. const gridRow = {
  841. id: row.id,
  842. created_at: row.created_at || '',
  843. edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit',
  844. };
  845. groups[id].attributes.forEach((attr, index) => {
  846. gridRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? '');
  847. });
  848. groups[id].rows.push(gridRow);
  849. });
  850. return Object.values(groups).sort((a, b) => a.name.localeCompare(b.name));
  851. },
  852. ensureAttributeColumns(group, attributeValues) {
  853. Object.keys(attributeValues).forEach((name) => {
  854. const exists = group.attributes.some((attr) => attr.name === name);
  855. if (!exists) {
  856. group.attributes.push({
  857. name: name,
  858. type: 'text',
  859. order: group.attributes.length + 1,
  860. });
  861. }
  862. });
  863. },
  864. normalizeAttributes(attributes) {
  865. return attributes
  866. .filter((attr) => attr && attr.name)
  867. .slice()
  868. .sort((a, b) => (a.order || 0) - (b.order || 0));
  869. },
  870. formatAttributeValue(value) {
  871. if (value === null || value === undefined) {
  872. return '';
  873. }
  874. if (Array.isArray(value) || typeof value === 'object') {
  875. return JSON.stringify(value);
  876. }
  877. return String(value);
  878. },
  879. initTables() {
  880. if (typeof Tabulator === 'undefined') {
  881. return;
  882. }
  883. this.groups.forEach((group) => {
  884. const el = document.getElementById(group.elementId);
  885. if (!el || this.tables[group.id]) {
  886. return;
  887. }
  888. this.tables[group.id] = new Tabulator(el, {
  889. data: group.rows,
  890. layout: 'fitColumns',
  891. responsiveLayout: 'collapse',
  892. pagination: true,
  893. paginationMode: 'local',
  894. paginationSize: 5,
  895. paginationSizeSelector: PAGE_SIZES_SM,
  896. movableColumns: true,
  897. placeholder: 'No jobs found for this job type.',
  898. initialSort: [{ column: 'created_at', dir: 'desc' }],
  899. columns: this.columnsForGroup(group),
  900. });
  901. });
  902. },
  903. columnsForGroup(group) {
  904. const actions = {
  905. title: 'Actions',
  906. field: 'edit_url',
  907. width: 90,
  908. hozAlign: 'center',
  909. headerSort: false,
  910. formatter: function (cell) {
  911. return '<a href="' + _escapeHtml(cell.getValue()) + '" class="button button-secondary button-sm">Edit</a>';
  912. },
  913. };
  914. const attrColumns = group.attributes.map((attr, index) => ({
  915. title: attr.name,
  916. field: 'attr_' + index,
  917. minWidth: 150,
  918. formatter: function (cell) {
  919. const value = cell.getValue();
  920. return value ? _escapeHtml(value) : '<span class="attr-empty">&mdash;</span>';
  921. },
  922. }));
  923. if (attrColumns.length === 0) {
  924. attrColumns.push({ title: 'Job ID', field: 'id', width: 90, hozAlign: 'center' });
  925. }
  926. return [actions, ...attrColumns, { title: 'Created', field: 'created_at', minWidth: 160 }];
  927. },
  928. destroyTables() {
  929. Object.values(this.tables).forEach((table) => {
  930. if (table && typeof table.destroy === 'function') {
  931. table.destroy();
  932. }
  933. });
  934. this.tables = {};
  935. },
  936. };
  937. };
  938. // Job Type
  939. window.jobTypeTable = function () {
  940. return {
  941. table: null,
  942. init() {
  943. this.initTable();
  944. },
  945. initTable() {
  946. const el = document.getElementById('job-type-table');
  947. if (!el || typeof Tabulator === 'undefined') {
  948. return;
  949. }
  950. this.table = new Tabulator(el, {
  951. ajaxURL: '/job-types/data',
  952. layout: 'fitColumns',
  953. responsiveLayout: 'collapse',
  954. pagination: true,
  955. paginationMode: 'local',
  956. paginationSize: 10,
  957. paginationSizeSelector: PAGE_SIZES,
  958. movableColumns: true,
  959. placeholder: 'No job types found.',
  960. initialSort: [{ column: 'name', dir: 'asc' }],
  961. columns: [
  962. {
  963. title: 'Actions',
  964. field: 'id',
  965. width: 160,
  966. hozAlign: 'center',
  967. headerSort: false,
  968. formatter: function (cell) {
  969. const id = cell.getValue();
  970. return '<a href="/job-types/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' +
  971. '<button onclick="window.deleteJobType(' + id + ')" class="button button-danger button-sm">Delete</button>';
  972. },
  973. },
  974. { title: 'Name', field: 'name', minWidth: 200 },
  975. {
  976. title: 'Attributes',
  977. field: 'attributes_summary',
  978. minWidth: 240,
  979. formatter: function (cell) {
  980. const v = cell.getValue();
  981. return v ? '<span class="attr-summary">' + _escapeHtml(v) + '</span>'
  982. : '<span class="attr-empty">&mdash;</span>';
  983. },
  984. },
  985. { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 },
  986. { title: 'Created', field: 'created_at', minWidth: 160 },
  987. ],
  988. });
  989. },
  990. reloadTable() {
  991. if (!this.table) {
  992. this.initTable();
  993. return;
  994. }
  995. this.table.setData('/job-types/data');
  996. },
  997. };
  998. };
  999. window.deleteJobType = function (id) {
  1000. if (!confirm('Delete this job type? This cannot be undone.')) {
  1001. return;
  1002. }
  1003. _postDelete('/job-types/' + id + '/delete');
  1004. };
  1005. window.jobTypeForm = function (initialAttributes) {
  1006. return {
  1007. attributes: Array.isArray(initialAttributes) ? initialAttributes : [],
  1008. dragIndex: null,
  1009. dragOverIndex: null,
  1010. addAttribute() {
  1011. 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' });
  1012. },
  1013. removeAttribute(index) {
  1014. this.attributes.splice(index, 1);
  1015. this.renumberOrder();
  1016. },
  1017. renumberOrder() {
  1018. this.attributes.forEach(function (attr, i) { attr.order = i + 1; });
  1019. },
  1020. dragStart(event, index) {
  1021. this.dragIndex = index;
  1022. event.dataTransfer.effectAllowed = 'move';
  1023. },
  1024. dragOver(event, index) {
  1025. this.dragOverIndex = index;
  1026. },
  1027. drop(event, index) {
  1028. if (this.dragIndex !== null && this.dragIndex !== index) {
  1029. var moved = this.attributes.splice(this.dragIndex, 1)[0];
  1030. this.attributes.splice(index, 0, moved);
  1031. this.renumberOrder();
  1032. }
  1033. this.dragIndex = null;
  1034. this.dragOverIndex = null;
  1035. },
  1036. dragEnd() {
  1037. this.dragIndex = null;
  1038. this.dragOverIndex = null;
  1039. },
  1040. confirmDelete(event) {
  1041. if (confirm('Delete this job type? This cannot be undone.')) {
  1042. event.target.submit();
  1043. }
  1044. },
  1045. };
  1046. };
  1047. // ── Job ───────────────────────────────────────────────────────────────────────
  1048. window.jobTable = function () {
  1049. return {
  1050. table: null,
  1051. isLoading: false,
  1052. errorMessage: '',
  1053. init() {
  1054. this.loadTable();
  1055. },
  1056. async loadTable() {
  1057. const el = document.getElementById('job-table');
  1058. if (!el || typeof Tabulator === 'undefined' || this.isLoading) {
  1059. return;
  1060. }
  1061. this.isLoading = true;
  1062. this.errorMessage = '';
  1063. try {
  1064. const response = await fetch('/jobs/data', {
  1065. headers: { Accept: 'application/json' },
  1066. });
  1067. if (!response.ok) {
  1068. throw new Error('Unable to load jobs.');
  1069. }
  1070. const rows = await response.json();
  1071. const jobRows = Array.isArray(rows) ? rows : [];
  1072. const attributes = this.attributeColumnsForRows(jobRows);
  1073. const tableRows = this.formatRows(jobRows, attributes);
  1074. const columns = this.columnsForAttributes(attributes);
  1075. if (this.table) {
  1076. this.table.destroy();
  1077. this.table = null;
  1078. }
  1079. this.table = new Tabulator(el, {
  1080. data: tableRows,
  1081. layout: 'fitData',
  1082. pagination: true,
  1083. paginationMode: 'local',
  1084. paginationSize: 10,
  1085. paginationSizeSelector: PAGE_SIZES,
  1086. movableColumns: true,
  1087. placeholder: 'No jobs found.',
  1088. initialSort: [{ column: 'job_type_name', dir: 'asc' }],
  1089. columns: columns,
  1090. });
  1091. } catch (error) {
  1092. this.errorMessage = error.message || 'Unable to load jobs.';
  1093. } finally {
  1094. this.isLoading = false;
  1095. }
  1096. },
  1097. attributeColumnsForRows(rows) {
  1098. const attributes = [];
  1099. rows.forEach((row) => {
  1100. this.normalizeAttributes(row.job_type_attributes || []).forEach((attr) => {
  1101. if (!attributes.some((existing) => existing.name === attr.name)) {
  1102. attributes.push(attr);
  1103. }
  1104. });
  1105. Object.keys(row.attribute_values || {}).forEach((name) => {
  1106. if (!attributes.some((existing) => existing.name === name)) {
  1107. attributes.push({ name: name, type: 'text', order: attributes.length + 1 });
  1108. }
  1109. });
  1110. });
  1111. return attributes;
  1112. },
  1113. normalizeAttributes(attributes) {
  1114. return attributes
  1115. .filter((attr) => attr && attr.name)
  1116. .slice()
  1117. .sort((a, b) => (a.order || 0) - (b.order || 0));
  1118. },
  1119. formatRows(rows, attributes) {
  1120. return rows.map((row) => {
  1121. const attributeValues = row.attribute_values || {};
  1122. const tableRow = {
  1123. id: row.id,
  1124. edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit',
  1125. campaign_id: row.campaign_id || '',
  1126. campaign_type_name: row.campaign_type_name || '',
  1127. job_type_id: row.job_type_id || '',
  1128. job_type_name: row.job_type_name || '',
  1129. created_at: row.created_at || '',
  1130. updated_at: row.updated_at || '',
  1131. };
  1132. attributes.forEach((attr, index) => {
  1133. tableRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? '');
  1134. });
  1135. return tableRow;
  1136. });
  1137. },
  1138. formatAttributeValue(value) {
  1139. if (value === null || value === undefined) {
  1140. return '';
  1141. }
  1142. if (Array.isArray(value) || typeof value === 'object') {
  1143. return JSON.stringify(value);
  1144. }
  1145. return String(value);
  1146. },
  1147. columnsForAttributes(attributes) {
  1148. const columns = [
  1149. {
  1150. title: 'Actions',
  1151. field: 'edit_url',
  1152. width: 160,
  1153. hozAlign: 'center',
  1154. headerSort: false,
  1155. formatter: function (cell) {
  1156. const url = cell.getValue();
  1157. const id = cell.getRow().getData().id;
  1158. return '<a href="' + _escapeHtml(url) + '" class="button button-secondary button-sm">Edit</a> ' +
  1159. '<button onclick="window.deleteJob(' + id + ')" class="button button-danger button-sm">Delete</button>';
  1160. },
  1161. },
  1162. { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' },
  1163. { title: 'Campaign', field: 'campaign_type_name', minWidth: 160, headerFilter: 'input' },
  1164. { title: 'Job Type', field: 'job_type_name', minWidth: 160, headerFilter: 'input' },
  1165. ];
  1166. attributes.forEach((attr, index) => {
  1167. columns.push({
  1168. title: attr.name,
  1169. field: 'attr_' + index,
  1170. minWidth: 150,
  1171. headerFilter: 'input',
  1172. formatter: function (cell) {
  1173. const value = cell.getValue();
  1174. return value ? _escapeHtml(value) : '<span class="attr-empty">&mdash;</span>';
  1175. },
  1176. });
  1177. });
  1178. columns.push(
  1179. { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' },
  1180. { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' }
  1181. );
  1182. return columns;
  1183. },
  1184. reloadTable() {
  1185. this.loadTable();
  1186. },
  1187. };
  1188. };
  1189. window.deleteJob = function (id) {
  1190. if (!confirm('Delete this job? This cannot be undone.')) {
  1191. return;
  1192. }
  1193. _postDelete('/jobs/' + id + '/delete');
  1194. };
  1195. window.jobForm = function (jobTypes, initialTypeId, initialValues) {
  1196. return {
  1197. jobTypes: jobTypes,
  1198. selectedTypeId: String(initialTypeId || ''),
  1199. attributeValues: Object.assign({}, initialValues || {}),
  1200. apiLookupState: {},
  1201. apiLookupError: {},
  1202. apiLookupOptions: {},
  1203. apiLookupOpen: {},
  1204. get currentType() {
  1205. var id = this.selectedTypeId;
  1206. if (!id) return null;
  1207. return this.jobTypes.find(function (t) { return String(t.id) === String(id); }) || null;
  1208. },
  1209. get currentAttributes() {
  1210. if (!this.currentType) return [];
  1211. return this.currentType.attributes.slice().sort(function (a, b) {
  1212. return (a.order || 0) - (b.order || 0);
  1213. });
  1214. },
  1215. init() {
  1216. var self = this;
  1217. this.currentAttributes.forEach(function (attr) {
  1218. if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
  1219. });
  1220. },
  1221. onTypeChange() {
  1222. this.attributeValues = {};
  1223. this.apiLookupOptions = {};
  1224. this.apiLookupState = {};
  1225. this.apiLookupOpen = {};
  1226. var self = this;
  1227. this.$nextTick(function () {
  1228. self.currentAttributes.forEach(function (attr) {
  1229. if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
  1230. });
  1231. });
  1232. },
  1233. inputType(attrType) {
  1234. return ['number', 'date'].includes(attrType) ? attrType : 'text';
  1235. },
  1236. getApiOptions(name) {
  1237. return this.apiLookupOptions[name] || { fields: [], records: [] };
  1238. },
  1239. openApiLookup(name) {
  1240. var o = {}; o[name] = true;
  1241. this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o);
  1242. },
  1243. closeApiLookup(name) {
  1244. var o = {}; o[name] = false;
  1245. this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o);
  1246. },
  1247. getFilteredRecords(name, search) {
  1248. var records = this.getApiOptions(name).records;
  1249. if (!search) return records;
  1250. var term = String(search).toLowerCase();
  1251. return records.filter(function (rec) {
  1252. return rec._display.some(function (v) {
  1253. return String(v).toLowerCase().indexOf(term) !== -1;
  1254. });
  1255. });
  1256. },
  1257. selectApiOption(attr, rec) {
  1258. var newValues = Object.assign({}, this.attributeValues);
  1259. newValues[attr.name] = rec._primary;
  1260. var autoFill = (attr.api_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean);
  1261. var attrs = this.currentAttributes;
  1262. autoFill.forEach(function (alias) {
  1263. var target = null;
  1264. for (var i = 0; i < attrs.length; i++) {
  1265. if (attrs[i].alias === alias) { target = attrs[i]; break; }
  1266. }
  1267. if (!target) return;
  1268. var rowVal = rec._row[alias];
  1269. if (rowVal !== undefined && rowVal !== null) {
  1270. newValues[target.name] = String(rowVal);
  1271. }
  1272. });
  1273. this.attributeValues = newValues;
  1274. this.closeApiLookup(attr.name);
  1275. },
  1276. fetchApiValue(attr) {
  1277. var self = this;
  1278. if (!attr.api_url) return;
  1279. var resolvedUrl = attr.api_url.replace(/\{alias\}/g, encodeURIComponent(attr.alias || ''));
  1280. // Reassign whole objects so Alpine sees new references and re-renders nested x-for
  1281. var s = {}; s[attr.name] = 'loading';
  1282. var e = {}; e[attr.name] = '';
  1283. var o = {}; o[attr.name] = { fields: [], records: [] };
  1284. self.apiLookupState = Object.assign({}, self.apiLookupState, s);
  1285. self.apiLookupError = Object.assign({}, self.apiLookupError, e);
  1286. self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, o);
  1287. var matchFields = (attr.api_match_field || '')
  1288. .split(';')
  1289. .map(function (s) { return s.trim(); })
  1290. .filter(Boolean);
  1291. fetch('/api/proxy?url=' + encodeURIComponent(resolvedUrl))
  1292. .then(function (res) { return res.json(); })
  1293. .then(function (envelope) {
  1294. if (envelope.error) {
  1295. var se = {}; se[attr.name] = envelope.error;
  1296. var ss = {}; ss[attr.name] = 'error';
  1297. self.apiLookupError = Object.assign({}, self.apiLookupError, se);
  1298. self.apiLookupState = Object.assign({}, self.apiLookupState, ss);
  1299. return;
  1300. }
  1301. var body = envelope.body || '';
  1302. var result = { fields: matchFields.slice(), records: [] };
  1303. var seenRows = [];
  1304. function addRow(rawRow) {
  1305. if (seenRows.indexOf(rawRow) !== -1) return;
  1306. seenRows.push(rawRow);
  1307. var display = result.fields.map(function (f) {
  1308. var v = rawRow[f]; return (v !== undefined && v !== null) ? String(v) : '';
  1309. });
  1310. result.records.push({ _primary: display[0] || '', _display: display, _row: rawRow });
  1311. }
  1312. if (attr.api_format === 'xml') {
  1313. try {
  1314. var doc = new DOMParser().parseFromString(body, 'text/xml');
  1315. if (result.fields.length === 0) { result.fields = [doc.documentElement.tagName]; }
  1316. // Collect sibling-based rows keyed by the first match field element
  1317. var firstField = result.fields[0];
  1318. var els = doc.getElementsByTagName(firstField);
  1319. for (var xi = 0; xi < els.length; xi++) {
  1320. var row = {};
  1321. var par = els[xi].parentNode;
  1322. if (par) {
  1323. for (var xc = 0; xc < par.childNodes.length; xc++) {
  1324. var cn = par.childNodes[xc];
  1325. if (cn.nodeType === 1) { row[cn.tagName] = cn.textContent.trim(); }
  1326. }
  1327. }
  1328. addRow(row);
  1329. }
  1330. } catch (e) { /* leave records empty */ }
  1331. } else {
  1332. try {
  1333. var parsed = JSON.parse(body);
  1334. if (result.fields.length > 0) {
  1335. // Collect unique parent rows that own the first match field
  1336. _deepFindRows(parsed, result.fields[0]).forEach(function (hit) { addRow(hit.row); });
  1337. } else if (Array.isArray(parsed)) {
  1338. result.fields = ['Value'];
  1339. parsed.forEach(function (item) {
  1340. if (typeof item !== 'object') { addRow({ Value: String(item) }); }
  1341. });
  1342. } else if (typeof parsed === 'string' || typeof parsed === 'number' || typeof parsed === 'boolean') {
  1343. result.fields = ['Value'];
  1344. addRow({ Value: String(parsed) });
  1345. }
  1346. } catch (e) { /* leave records empty */ }
  1347. }
  1348. var oo = {}; oo[attr.name] = result;
  1349. var os = {}; os[attr.name] = 'idle';
  1350. self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, oo);
  1351. self.apiLookupState = Object.assign({}, self.apiLookupState, os);
  1352. })
  1353. .catch(function (err) {
  1354. console.error('[api-lookup] fetch failed:', err);
  1355. var ce = {}; ce[attr.name] = 'Network error — see browser console.';
  1356. var cs = {}; cs[attr.name] = 'error';
  1357. self.apiLookupError = Object.assign({}, self.apiLookupError, ce);
  1358. self.apiLookupState = Object.assign({}, self.apiLookupState, cs);
  1359. });
  1360. },
  1361. confirmDelete(event) {
  1362. if (confirm('Delete this job? This cannot be undone.')) {
  1363. event.target.submit();
  1364. }
  1365. },
  1366. };
  1367. };
  1368. // Unsaved-changes guard — fires beforeunload warning when a .ct-form has been
  1369. // touched but not yet submitted. Delete forms and the logout form are excluded
  1370. // because they use different CSS classes and are intentional navigation.
  1371. (function () {
  1372. function initDirtyFormGuard() {
  1373. var forms = document.querySelectorAll('form.ct-form');
  1374. if (!forms.length) return;
  1375. var dirty = false;
  1376. function markDirty() { dirty = true; }
  1377. function markClean() { dirty = false; }
  1378. forms.forEach(function (form) {
  1379. form.addEventListener('input', markDirty);
  1380. form.addEventListener('change', markDirty);
  1381. form.addEventListener('submit', markClean);
  1382. });
  1383. window.addEventListener('beforeunload', function (e) {
  1384. if (!dirty) return;
  1385. e.preventDefault();
  1386. e.returnValue = '';
  1387. });
  1388. }
  1389. if (document.readyState === 'loading') {
  1390. document.addEventListener('DOMContentLoaded', initDirtyFormGuard);
  1391. } else {
  1392. initDirtyFormGuard();
  1393. }
  1394. }());

Powered by TurnKey Linux.