Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

2302 Zeilen
87KB

  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, customerTypes) {
  1006. return {
  1007. attributes: Array.isArray(initialAttributes) ? initialAttributes : [],
  1008. customerTypes: Array.isArray(customerTypes) ? customerTypes : [],
  1009. dragIndex: null,
  1010. dragOverIndex: null,
  1011. addAttribute() {
  1012. this.attributes.push({
  1013. name: '', type: 'text', alias: '', order: this.attributes.length + 1,
  1014. api_url: '', api_match_field: '', api_auto_fill: '', api_format: 'json', api_return_type: 'text',
  1015. customer_type_id: 0,
  1016. db_match_field: '', db_auto_fill: '', db_customer_type_id: 0,
  1017. _uid: Math.random().toString(36).slice(2), _imported_by: '',
  1018. });
  1019. },
  1020. importCustomerTypeAttributes(index) {
  1021. var row = this.attributes[index];
  1022. if (!row) return;
  1023. var ctId = Number(row.customer_type_id || 0);
  1024. if (!ctId) return;
  1025. var ct = this.customerTypes.find(function (c) { return Number(c.id) === ctId; });
  1026. if (!ct || !Array.isArray(ct.attributes) || ct.attributes.length === 0) {
  1027. row.customer_type_id = 0;
  1028. return;
  1029. }
  1030. var imported = ct.attributes.slice().sort(function (a, b) {
  1031. return (a.order || 0) - (b.order || 0);
  1032. }).map(function (a) {
  1033. return {
  1034. name: a.name || '',
  1035. type: a.type || 'text',
  1036. alias: a.alias || '',
  1037. order: 0,
  1038. api_url: a.api_url || '',
  1039. api_match_field: a.api_match_field || '',
  1040. api_auto_fill: a.api_auto_fill || '',
  1041. api_format: a.api_format || 'json',
  1042. api_return_type: a.api_return_type || 'text',
  1043. customer_type_id: 0,
  1044. db_match_field: a.db_match_field || '',
  1045. db_auto_fill: a.db_auto_fill || '',
  1046. db_customer_type_id: 0,
  1047. _uid: Math.random().toString(36).slice(2), _imported_by: '',
  1048. };
  1049. });
  1050. this.attributes.splice.apply(this.attributes, [index, 1].concat(imported));
  1051. this.renumberOrder();
  1052. },
  1053. removeAttribute(index) {
  1054. this.attributes.splice(index, 1);
  1055. this.renumberOrder();
  1056. },
  1057. renumberOrder() {
  1058. this.attributes.forEach(function (attr, i) { attr.order = i + 1; });
  1059. },
  1060. dragStart(event, index) {
  1061. this.dragIndex = index;
  1062. event.dataTransfer.effectAllowed = 'move';
  1063. },
  1064. dragOver(event, index) {
  1065. this.dragOverIndex = index;
  1066. },
  1067. drop(event, index) {
  1068. if (this.dragIndex !== null && this.dragIndex !== index) {
  1069. var moved = this.attributes.splice(this.dragIndex, 1)[0];
  1070. this.attributes.splice(index, 0, moved);
  1071. this.renumberOrder();
  1072. }
  1073. this.dragIndex = null;
  1074. this.dragOverIndex = null;
  1075. },
  1076. dragEnd() {
  1077. this.dragIndex = null;
  1078. this.dragOverIndex = null;
  1079. },
  1080. confirmDelete(event) {
  1081. if (confirm('Delete this job type? This cannot be undone.')) {
  1082. event.target.submit();
  1083. }
  1084. },
  1085. getCustomerTypeAttrs(customerTypeId) {
  1086. var ct = this.customerTypes.find(function (c) { return Number(c.id) === Number(customerTypeId); });
  1087. if (!ct || !Array.isArray(ct.attributes)) return [];
  1088. return ct.attributes
  1089. .filter(function (a) { return a.name && a.name.trim(); })
  1090. .slice()
  1091. .sort(function (a, b) { return (a.order || 0) - (b.order || 0); });
  1092. },
  1093. onDbCustomerTypeChange(attr) {
  1094. var self = this;
  1095. if (!attr._uid) { attr._uid = Math.random().toString(36).slice(2); }
  1096. var uid = attr._uid;
  1097. // Remove rows previously imported by this lookup (current session only)
  1098. for (var i = self.attributes.length - 1; i >= 0; i--) {
  1099. if (self.attributes[i]._imported_by === uid) {
  1100. self.attributes.splice(i, 1);
  1101. }
  1102. }
  1103. attr.db_match_field = '';
  1104. attr.db_auto_fill = '';
  1105. var ct = self.customerTypes.find(function (c) { return Number(c.id) === Number(attr.db_customer_type_id); });
  1106. if (!ct) return;
  1107. if (!attr.name.trim()) { attr.name = ct.name; }
  1108. var ctAttrs = self.getCustomerTypeAttrs(attr.db_customer_type_id);
  1109. if (ctAttrs.length === 0) return;
  1110. // db_match_field and db_auto_fill use attribute NAMES (not aliases)
  1111. // so they work regardless of whether aliases are configured
  1112. attr.db_match_field = ctAttrs[0].name;
  1113. attr.db_auto_fill = ctAttrs.map(function (a) { return a.name; }).join(';');
  1114. // Import ALL CT attributes as regular rows after this lookup row
  1115. var lookupIndex = self.attributes.indexOf(attr);
  1116. var rows = ctAttrs.map(function (a) {
  1117. var type = a.type || 'text';
  1118. if (type === 'customer_lookup' || type === 'database_lookup') { type = 'text'; }
  1119. return {
  1120. name: a.name || '', type: type, alias: a.alias || '', order: 0,
  1121. api_url: a.api_url || '', api_match_field: a.api_match_field || '',
  1122. api_auto_fill: a.api_auto_fill || '', api_format: a.api_format || 'json',
  1123. api_return_type: a.api_return_type || 'text',
  1124. customer_type_id: 0, db_match_field: '', db_auto_fill: '', db_customer_type_id: 0,
  1125. _uid: Math.random().toString(36).slice(2), _imported_by: uid,
  1126. };
  1127. });
  1128. self.attributes.splice.apply(self.attributes, [lookupIndex + 1, 0].concat(rows));
  1129. self.renumberOrder();
  1130. },
  1131. onAttributeTypeChange(index) {
  1132. var self = this;
  1133. var attr = self.attributes[index];
  1134. if (!attr || attr.type !== 'customer_lookup') return;
  1135. for (var i = self.attributes.length - 1; i >= 0; i--) {
  1136. if (self.attributes[i] !== attr && self.attributes[i].type === 'api_lookup') {
  1137. self.attributes.splice(i, 1);
  1138. }
  1139. }
  1140. self.renumberOrder();
  1141. },
  1142. guardSubmit(event) {
  1143. var missing = this.attributes.filter(function (a) {
  1144. return a.type === 'customer_lookup' && !a.name.trim();
  1145. });
  1146. if (missing.length > 0) {
  1147. event.preventDefault();
  1148. alert('One or more Database Lookup attributes are missing a name. Fill in the attribute name field before saving.');
  1149. }
  1150. },
  1151. };
  1152. };
  1153. // ── Job ───────────────────────────────────────────────────────────────────────
  1154. window.jobTable = function () {
  1155. return {
  1156. table: null,
  1157. isLoading: false,
  1158. errorMessage: '',
  1159. init() {
  1160. this.loadTable();
  1161. },
  1162. async loadTable() {
  1163. const el = document.getElementById('job-table');
  1164. if (!el || typeof Tabulator === 'undefined' || this.isLoading) {
  1165. return;
  1166. }
  1167. this.isLoading = true;
  1168. this.errorMessage = '';
  1169. try {
  1170. const response = await fetch('/jobs/data', {
  1171. headers: { Accept: 'application/json' },
  1172. });
  1173. if (!response.ok) {
  1174. throw new Error('Unable to load jobs.');
  1175. }
  1176. const rows = await response.json();
  1177. const jobRows = Array.isArray(rows) ? rows : [];
  1178. const attributes = this.attributeColumnsForRows(jobRows);
  1179. const tableRows = this.formatRows(jobRows, attributes);
  1180. const columns = this.columnsForAttributes(attributes);
  1181. if (this.table) {
  1182. this.table.destroy();
  1183. this.table = null;
  1184. }
  1185. this.table = new Tabulator(el, {
  1186. data: tableRows,
  1187. layout: 'fitData',
  1188. pagination: true,
  1189. paginationMode: 'local',
  1190. paginationSize: 10,
  1191. paginationSizeSelector: PAGE_SIZES,
  1192. movableColumns: true,
  1193. placeholder: 'No jobs found.',
  1194. initialSort: [{ column: 'job_type_name', dir: 'asc' }],
  1195. columns: columns,
  1196. });
  1197. } catch (error) {
  1198. this.errorMessage = error.message || 'Unable to load jobs.';
  1199. } finally {
  1200. this.isLoading = false;
  1201. }
  1202. },
  1203. attributeColumnsForRows(rows) {
  1204. const attributes = [];
  1205. rows.forEach((row) => {
  1206. this.normalizeAttributes(row.job_type_attributes || []).forEach((attr) => {
  1207. if (!attributes.some((existing) => existing.name === attr.name)) {
  1208. attributes.push(attr);
  1209. }
  1210. });
  1211. Object.keys(row.attribute_values || {}).forEach((name) => {
  1212. if (!attributes.some((existing) => existing.name === name)) {
  1213. attributes.push({ name: name, type: 'text', order: attributes.length + 1 });
  1214. }
  1215. });
  1216. });
  1217. return attributes;
  1218. },
  1219. normalizeAttributes(attributes) {
  1220. return attributes
  1221. .filter((attr) => attr && attr.name)
  1222. .slice()
  1223. .sort((a, b) => (a.order || 0) - (b.order || 0));
  1224. },
  1225. formatRows(rows, attributes) {
  1226. return rows.map((row) => {
  1227. const attributeValues = row.attribute_values || {};
  1228. const tableRow = {
  1229. id: row.id,
  1230. edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit',
  1231. campaign_id: row.campaign_id || '',
  1232. campaign_type_name: row.campaign_type_name || '',
  1233. job_type_id: row.job_type_id || '',
  1234. job_type_name: row.job_type_name || '',
  1235. created_at: row.created_at || '',
  1236. updated_at: row.updated_at || '',
  1237. };
  1238. attributes.forEach((attr, index) => {
  1239. tableRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? '');
  1240. });
  1241. return tableRow;
  1242. });
  1243. },
  1244. formatAttributeValue(value) {
  1245. if (value === null || value === undefined) {
  1246. return '';
  1247. }
  1248. if (Array.isArray(value) || typeof value === 'object') {
  1249. return JSON.stringify(value);
  1250. }
  1251. return String(value);
  1252. },
  1253. columnsForAttributes(attributes) {
  1254. const columns = [
  1255. {
  1256. title: 'Actions',
  1257. field: 'edit_url',
  1258. width: 160,
  1259. hozAlign: 'center',
  1260. headerSort: false,
  1261. formatter: function (cell) {
  1262. const url = cell.getValue();
  1263. const id = cell.getRow().getData().id;
  1264. return '<a href="' + _escapeHtml(url) + '" class="button button-secondary button-sm">Edit</a> ' +
  1265. '<button onclick="window.deleteJob(' + id + ')" class="button button-danger button-sm">Delete</button>';
  1266. },
  1267. },
  1268. { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' },
  1269. { title: 'Campaign', field: 'campaign_type_name', minWidth: 160, headerFilter: 'input' },
  1270. { title: 'Job Type', field: 'job_type_name', minWidth: 160, headerFilter: 'input' },
  1271. ];
  1272. attributes.forEach((attr, index) => {
  1273. columns.push({
  1274. title: attr.name,
  1275. field: 'attr_' + index,
  1276. minWidth: 150,
  1277. headerFilter: 'input',
  1278. formatter: function (cell) {
  1279. const value = cell.getValue();
  1280. return value ? _escapeHtml(value) : '<span class="attr-empty">&mdash;</span>';
  1281. },
  1282. });
  1283. });
  1284. columns.push(
  1285. { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' },
  1286. { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' }
  1287. );
  1288. return columns;
  1289. },
  1290. reloadTable() {
  1291. this.loadTable();
  1292. },
  1293. };
  1294. };
  1295. window.deleteJob = function (id) {
  1296. if (!confirm('Delete this job? This cannot be undone.')) {
  1297. return;
  1298. }
  1299. _postDelete('/jobs/' + id + '/delete');
  1300. };
  1301. window.jobForm = function (jobTypes, initialTypeId, initialValues) {
  1302. return {
  1303. jobTypes: jobTypes,
  1304. selectedTypeId: String(initialTypeId || ''),
  1305. attributeValues: Object.assign({}, initialValues || {}),
  1306. apiLookupState: {},
  1307. apiLookupError: {},
  1308. apiLookupOptions: {},
  1309. apiLookupOpen: {},
  1310. get currentType() {
  1311. var id = this.selectedTypeId;
  1312. if (!id) return null;
  1313. return this.jobTypes.find(function (t) { return String(t.id) === String(id); }) || null;
  1314. },
  1315. get currentAttributes() {
  1316. if (!this.currentType) return [];
  1317. var attrs = this.currentType.attributes.slice().sort(function (a, b) {
  1318. return (a.order || 0) - (b.order || 0);
  1319. });
  1320. var hasCustomerLookup = attrs.some(function (a) { return a.type === 'customer_lookup'; });
  1321. if (hasCustomerLookup) {
  1322. attrs = attrs.filter(function (a) { return a.type !== 'api_lookup'; });
  1323. }
  1324. return attrs;
  1325. },
  1326. init() {
  1327. var self = this;
  1328. this.currentAttributes.forEach(function (attr) {
  1329. if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
  1330. if (attr.type === 'customer_lookup') { self.fetchDbLookupValue(attr); }
  1331. });
  1332. },
  1333. onTypeChange() {
  1334. this.attributeValues = {};
  1335. this.apiLookupOptions = {};
  1336. this.apiLookupState = {};
  1337. this.apiLookupOpen = {};
  1338. var self = this;
  1339. this.$nextTick(function () {
  1340. self.currentAttributes.forEach(function (attr) {
  1341. if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
  1342. if (attr.type === 'customer_lookup') { self.fetchDbLookupValue(attr); }
  1343. });
  1344. });
  1345. },
  1346. inputType(attrType) {
  1347. return ['number', 'date'].includes(attrType) ? attrType : 'text';
  1348. },
  1349. getApiOptions(name) {
  1350. return this.apiLookupOptions[name] || { fields: [], records: [] };
  1351. },
  1352. openApiLookup(name) {
  1353. var o = {}; o[name] = true;
  1354. this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o);
  1355. },
  1356. closeApiLookup(name) {
  1357. var o = {}; o[name] = false;
  1358. this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o);
  1359. },
  1360. getFilteredRecords(name, search) {
  1361. var records = this.getApiOptions(name).records;
  1362. if (!search) return records;
  1363. var term = String(search).toLowerCase();
  1364. return records.filter(function (rec) {
  1365. return rec._display.some(function (v) {
  1366. return String(v).toLowerCase().indexOf(term) !== -1;
  1367. });
  1368. });
  1369. },
  1370. selectApiOption(attr, rec) {
  1371. var newValues = Object.assign({}, this.attributeValues);
  1372. newValues[attr.name] = rec._primary;
  1373. if (attr.type === 'customer_lookup') {
  1374. // Auto-fill by attribute NAME: _row is name-keyed, db_auto_fill stores names
  1375. var names = (attr.db_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean);
  1376. names.forEach(function (name) {
  1377. var rowVal = rec._row[name];
  1378. if (rowVal !== undefined && rowVal !== null) {
  1379. newValues[name] = String(rowVal);
  1380. }
  1381. });
  1382. } else {
  1383. // api_lookup: auto-fill by alias
  1384. var autoFill = (attr.api_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean);
  1385. var attrs = this.currentAttributes;
  1386. autoFill.forEach(function (alias) {
  1387. var target = null;
  1388. for (var i = 0; i < attrs.length; i++) {
  1389. if (attrs[i].alias === alias) { target = attrs[i]; break; }
  1390. }
  1391. if (!target) return;
  1392. var rowVal = rec._row[alias];
  1393. if (rowVal !== undefined && rowVal !== null) {
  1394. newValues[target.name] = String(rowVal);
  1395. }
  1396. });
  1397. }
  1398. this.attributeValues = newValues;
  1399. this.closeApiLookup(attr.name);
  1400. },
  1401. fetchApiValue(attr) {
  1402. var self = this;
  1403. if (!attr.api_url) return;
  1404. var resolvedUrl = attr.api_url.replace(/\{alias\}/g, encodeURIComponent(attr.alias || ''));
  1405. // Reassign whole objects so Alpine sees new references and re-renders nested x-for
  1406. var s = {}; s[attr.name] = 'loading';
  1407. var e = {}; e[attr.name] = '';
  1408. var o = {}; o[attr.name] = { fields: [], records: [] };
  1409. self.apiLookupState = Object.assign({}, self.apiLookupState, s);
  1410. self.apiLookupError = Object.assign({}, self.apiLookupError, e);
  1411. self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, o);
  1412. var matchFields = (attr.api_match_field || '')
  1413. .split(';')
  1414. .map(function (s) { return s.trim(); })
  1415. .filter(Boolean);
  1416. fetch('/api/proxy?url=' + encodeURIComponent(resolvedUrl))
  1417. .then(function (res) { return res.json(); })
  1418. .then(function (envelope) {
  1419. if (envelope.error) {
  1420. var se = {}; se[attr.name] = envelope.error;
  1421. var ss = {}; ss[attr.name] = 'error';
  1422. self.apiLookupError = Object.assign({}, self.apiLookupError, se);
  1423. self.apiLookupState = Object.assign({}, self.apiLookupState, ss);
  1424. return;
  1425. }
  1426. var body = envelope.body || '';
  1427. var result = { fields: matchFields.slice(), records: [] };
  1428. var seenRows = [];
  1429. function addRow(rawRow) {
  1430. if (seenRows.indexOf(rawRow) !== -1) return;
  1431. seenRows.push(rawRow);
  1432. var display = result.fields.map(function (f) {
  1433. var v = rawRow[f]; return (v !== undefined && v !== null) ? String(v) : '';
  1434. });
  1435. result.records.push({ _primary: display[0] || '', _display: display, _row: rawRow });
  1436. }
  1437. if (attr.api_format === 'xml') {
  1438. try {
  1439. var doc = new DOMParser().parseFromString(body, 'text/xml');
  1440. if (result.fields.length === 0) { result.fields = [doc.documentElement.tagName]; }
  1441. // Collect sibling-based rows keyed by the first match field element
  1442. var firstField = result.fields[0];
  1443. var els = doc.getElementsByTagName(firstField);
  1444. for (var xi = 0; xi < els.length; xi++) {
  1445. var row = {};
  1446. var par = els[xi].parentNode;
  1447. if (par) {
  1448. for (var xc = 0; xc < par.childNodes.length; xc++) {
  1449. var cn = par.childNodes[xc];
  1450. if (cn.nodeType === 1) { row[cn.tagName] = cn.textContent.trim(); }
  1451. }
  1452. }
  1453. addRow(row);
  1454. }
  1455. } catch (e) { /* leave records empty */ }
  1456. } else {
  1457. try {
  1458. var parsed = JSON.parse(body);
  1459. if (result.fields.length > 0) {
  1460. // Collect unique parent rows that own the first match field
  1461. _deepFindRows(parsed, result.fields[0]).forEach(function (hit) { addRow(hit.row); });
  1462. } else if (Array.isArray(parsed)) {
  1463. result.fields = ['Value'];
  1464. parsed.forEach(function (item) {
  1465. if (typeof item !== 'object') { addRow({ Value: String(item) }); }
  1466. });
  1467. } else if (typeof parsed === 'string' || typeof parsed === 'number' || typeof parsed === 'boolean') {
  1468. result.fields = ['Value'];
  1469. addRow({ Value: String(parsed) });
  1470. }
  1471. } catch (e) { /* leave records empty */ }
  1472. }
  1473. var oo = {}; oo[attr.name] = result;
  1474. var os = {}; os[attr.name] = 'idle';
  1475. self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, oo);
  1476. self.apiLookupState = Object.assign({}, self.apiLookupState, os);
  1477. })
  1478. .catch(function (err) {
  1479. console.error('[api-lookup] fetch failed:', err);
  1480. var ce = {}; ce[attr.name] = 'Network error — see browser console.';
  1481. var cs = {}; cs[attr.name] = 'error';
  1482. self.apiLookupError = Object.assign({}, self.apiLookupError, ce);
  1483. self.apiLookupState = Object.assign({}, self.apiLookupState, cs);
  1484. });
  1485. },
  1486. fetchDbLookupValue(attr) {
  1487. var self = this;
  1488. var typeId = Number(attr.db_customer_type_id || 0);
  1489. if (!typeId) return;
  1490. var matchField = attr.db_match_field || '';
  1491. var url = '/customers/lookup?type_id=' + typeId;
  1492. if (matchField) { url += '&match_field=' + encodeURIComponent(matchField); }
  1493. // Feed into the same apiLookup* state so customer_lookup uses the api_lookup UI
  1494. var s = {}; s[attr.name] = 'loading';
  1495. var e = {}; e[attr.name] = '';
  1496. var o = {}; o[attr.name] = { fields: [], records: [] };
  1497. self.apiLookupState = Object.assign({}, self.apiLookupState, s);
  1498. self.apiLookupError = Object.assign({}, self.apiLookupError, e);
  1499. self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, o);
  1500. fetch(url, { headers: { Accept: 'application/json' } })
  1501. .then(function (res) { return res.json(); })
  1502. .then(function (data) {
  1503. var oo = {}; oo[attr.name] = { fields: data.fields || [], records: data.records || [] };
  1504. var os = {}; os[attr.name] = 'idle';
  1505. self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, oo);
  1506. self.apiLookupState = Object.assign({}, self.apiLookupState, os);
  1507. })
  1508. .catch(function (err) {
  1509. console.error('[customer-lookup] fetch failed:', err);
  1510. var ce = {}; ce[attr.name] = 'Network error — see browser console.';
  1511. var cs = {}; cs[attr.name] = 'error';
  1512. self.apiLookupError = Object.assign({}, self.apiLookupError, ce);
  1513. self.apiLookupState = Object.assign({}, self.apiLookupState, cs);
  1514. });
  1515. },
  1516. confirmDelete(event) {
  1517. if (confirm('Delete this job? This cannot be undone.')) {
  1518. event.target.submit();
  1519. }
  1520. },
  1521. };
  1522. };
  1523. // ── Customer Type ─────────────────────────────────────────────────────────────
  1524. window.customerTypeTable = function () {
  1525. return {
  1526. table: null,
  1527. init() {
  1528. this.initTable();
  1529. },
  1530. initTable() {
  1531. const el = document.getElementById('customer-type-table');
  1532. if (!el || typeof Tabulator === 'undefined') {
  1533. return;
  1534. }
  1535. this.table = new Tabulator(el, {
  1536. ajaxURL: '/customer-types/data',
  1537. layout: 'fitColumns',
  1538. responsiveLayout: 'collapse',
  1539. pagination: true,
  1540. paginationMode: 'local',
  1541. paginationSize: 10,
  1542. paginationSizeSelector: PAGE_SIZES,
  1543. movableColumns: true,
  1544. placeholder: 'No customer types found.',
  1545. initialSort: [{ column: 'name', dir: 'asc' }],
  1546. columns: [
  1547. {
  1548. title: 'Actions',
  1549. field: 'id',
  1550. width: 160,
  1551. hozAlign: 'center',
  1552. headerSort: false,
  1553. formatter: function (cell) {
  1554. const id = cell.getValue();
  1555. return '<a href="/customer-types/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' +
  1556. '<button onclick="window.deleteCustomerType(' + id + ')" class="button button-danger button-sm">Delete</button>';
  1557. },
  1558. },
  1559. { title: 'Name', field: 'name', minWidth: 200 },
  1560. {
  1561. title: 'Attributes',
  1562. field: 'attributes_summary',
  1563. minWidth: 240,
  1564. formatter: function (cell) {
  1565. const v = cell.getValue();
  1566. return v ? '<span class="attr-summary">' + _escapeHtml(v) + '</span>'
  1567. : '<span class="attr-empty">&mdash;</span>';
  1568. },
  1569. },
  1570. { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 },
  1571. { title: 'Created', field: 'created_at', minWidth: 160 },
  1572. ],
  1573. });
  1574. },
  1575. reloadTable() {
  1576. if (!this.table) {
  1577. this.initTable();
  1578. return;
  1579. }
  1580. this.table.setData('/customer-types/data');
  1581. },
  1582. };
  1583. };
  1584. window.deleteCustomerType = function (id) {
  1585. if (!confirm('Delete this customer type? This cannot be undone.')) {
  1586. return;
  1587. }
  1588. _postDelete('/customer-types/' + id + '/delete');
  1589. };
  1590. window.customerTypeForm = function (initialAttributes) {
  1591. return {
  1592. attributes: Array.isArray(initialAttributes) ? initialAttributes : [],
  1593. dragIndex: null,
  1594. dragOverIndex: null,
  1595. addAttribute() {
  1596. this.attributes.push({ name: '', type: 'text', alias: '', order: this.attributes.length + 1, api_url: '', api_match_field: '', api_auto_fill: '', api_format: 'json', api_return_type: 'text', db_match_field: '', db_auto_fill: '' });
  1597. },
  1598. removeAttribute(index) {
  1599. this.attributes.splice(index, 1);
  1600. this.renumberOrder();
  1601. },
  1602. renumberOrder() {
  1603. this.attributes.forEach(function (attr, i) { attr.order = i + 1; });
  1604. },
  1605. dragStart(event, index) {
  1606. this.dragIndex = index;
  1607. event.dataTransfer.effectAllowed = 'move';
  1608. },
  1609. dragOver(event, index) {
  1610. this.dragOverIndex = index;
  1611. },
  1612. drop(event, index) {
  1613. if (this.dragIndex !== null && this.dragIndex !== index) {
  1614. var moved = this.attributes.splice(this.dragIndex, 1)[0];
  1615. this.attributes.splice(index, 0, moved);
  1616. this.renumberOrder();
  1617. }
  1618. this.dragIndex = null;
  1619. this.dragOverIndex = null;
  1620. },
  1621. dragEnd() {
  1622. this.dragIndex = null;
  1623. this.dragOverIndex = null;
  1624. },
  1625. confirmDelete(event) {
  1626. if (confirm('Delete this customer type? This cannot be undone.')) {
  1627. event.target.submit();
  1628. }
  1629. },
  1630. };
  1631. };
  1632. // ── Customer ──────────────────────────────────────────────────────────────────
  1633. window.customerTable = function () {
  1634. return {
  1635. table: null,
  1636. isLoading: false,
  1637. errorMessage: '',
  1638. init() {
  1639. this.loadTable();
  1640. },
  1641. async loadTable() {
  1642. const el = document.getElementById('customer-table');
  1643. if (!el || typeof Tabulator === 'undefined' || this.isLoading) {
  1644. return;
  1645. }
  1646. this.isLoading = true;
  1647. this.errorMessage = '';
  1648. try {
  1649. const response = await fetch('/customers/data', {
  1650. headers: { Accept: 'application/json' },
  1651. });
  1652. if (!response.ok) {
  1653. throw new Error('Unable to load customers.');
  1654. }
  1655. const rows = await response.json();
  1656. const customerRows = Array.isArray(rows) ? rows : [];
  1657. const attributes = this.attributeColumnsForRows(customerRows);
  1658. const tableRows = this.formatRows(customerRows, attributes);
  1659. const columns = this.columnsForAttributes(attributes);
  1660. if (this.table) {
  1661. this.table.destroy();
  1662. this.table = null;
  1663. }
  1664. this.table = new Tabulator(el, {
  1665. data: tableRows,
  1666. layout: 'fitData',
  1667. pagination: true,
  1668. paginationMode: 'local',
  1669. paginationSize: 10,
  1670. paginationSizeSelector: PAGE_SIZES,
  1671. movableColumns: true,
  1672. placeholder: 'No customers found.',
  1673. initialSort: [{ column: 'customer_type_name', dir: 'asc' }],
  1674. columns: columns,
  1675. });
  1676. } catch (error) {
  1677. this.errorMessage = error.message || 'Unable to load customers.';
  1678. } finally {
  1679. this.isLoading = false;
  1680. }
  1681. },
  1682. attributeColumnsForRows(rows) {
  1683. const attributes = [];
  1684. rows.forEach((row) => {
  1685. this.normalizeAttributes(row.customer_type_attributes || []).forEach((attr) => {
  1686. if (!attributes.some((existing) => existing.name === attr.name)) {
  1687. attributes.push(attr);
  1688. }
  1689. });
  1690. Object.keys(row.attribute_values || {}).forEach((name) => {
  1691. if (!attributes.some((existing) => existing.name === name)) {
  1692. attributes.push({ name: name, type: 'text', order: attributes.length + 1 });
  1693. }
  1694. });
  1695. });
  1696. return attributes;
  1697. },
  1698. normalizeAttributes(attributes) {
  1699. return attributes
  1700. .filter((attr) => attr && attr.name)
  1701. .slice()
  1702. .sort((a, b) => (a.order || 0) - (b.order || 0));
  1703. },
  1704. formatRows(rows, attributes) {
  1705. return rows.map((row) => {
  1706. const attributeValues = row.attribute_values || {};
  1707. const tableRow = {
  1708. id: row.id,
  1709. edit_url: '/customers/' + encodeURIComponent(row.id) + '/edit',
  1710. customer_type_id: row.customer_type_id || '',
  1711. customer_type_name: row.customer_type_name || '',
  1712. created_at: row.created_at || '',
  1713. };
  1714. attributes.forEach((attr, index) => {
  1715. tableRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? '');
  1716. });
  1717. return tableRow;
  1718. });
  1719. },
  1720. formatAttributeValue(value) {
  1721. if (value === null || value === undefined) {
  1722. return '';
  1723. }
  1724. if (Array.isArray(value) || typeof value === 'object') {
  1725. return JSON.stringify(value);
  1726. }
  1727. return String(value);
  1728. },
  1729. columnsForAttributes(attributes) {
  1730. const columns = [
  1731. {
  1732. title: 'Actions',
  1733. field: 'edit_url',
  1734. width: 160,
  1735. hozAlign: 'center',
  1736. headerSort: false,
  1737. formatter: function (cell) {
  1738. const url = cell.getValue();
  1739. const id = cell.getRow().getData().id;
  1740. return '<a href="' + _escapeHtml(url) + '" class="button button-secondary button-sm">Edit</a> ' +
  1741. '<button onclick="window.deleteCustomer(' + id + ')" class="button button-danger button-sm">Delete</button>';
  1742. },
  1743. },
  1744. { title: 'Customer ID', field: 'id', width: 110, hozAlign: 'center', headerFilter: 'input' },
  1745. { title: 'Customer Type', field: 'customer_type_name', minWidth: 180, headerFilter: 'input' },
  1746. ];
  1747. attributes.forEach((attr, index) => {
  1748. columns.push({
  1749. title: attr.name,
  1750. field: 'attr_' + index,
  1751. minWidth: 150,
  1752. headerFilter: 'input',
  1753. formatter: function (cell) {
  1754. const value = cell.getValue();
  1755. return value ? _escapeHtml(value) : '<span class="attr-empty">&mdash;</span>';
  1756. },
  1757. });
  1758. });
  1759. columns.push(
  1760. { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' }
  1761. );
  1762. return columns;
  1763. },
  1764. reloadTable() {
  1765. this.loadTable();
  1766. },
  1767. };
  1768. };
  1769. window.deleteCustomer = function (id) {
  1770. if (!confirm('Delete this customer? This cannot be undone.')) {
  1771. return;
  1772. }
  1773. _postDelete('/customers/' + id + '/delete');
  1774. };
  1775. window.customerForm = function (customerTypes, initialTypeId, initialValues) {
  1776. return {
  1777. customerTypes: customerTypes,
  1778. selectedTypeId: String(initialTypeId || ''),
  1779. attributeValues: Object.assign({}, initialValues || {}),
  1780. apiLookupState: {},
  1781. apiLookupError: {},
  1782. apiLookupOptions: {},
  1783. apiLookupOpen: {},
  1784. get currentType() {
  1785. var id = this.selectedTypeId;
  1786. if (!id) return null;
  1787. return this.customerTypes.find(function (t) { return String(t.id) === String(id); }) || null;
  1788. },
  1789. get currentAttributes() {
  1790. if (!this.currentType) return [];
  1791. return this.currentType.attributes.slice().sort(function (a, b) {
  1792. return (a.order || 0) - (b.order || 0);
  1793. });
  1794. },
  1795. init() {
  1796. var self = this;
  1797. this.currentAttributes.forEach(function (attr) {
  1798. if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
  1799. });
  1800. },
  1801. onTypeChange() {
  1802. this.attributeValues = {};
  1803. this.apiLookupOptions = {};
  1804. this.apiLookupState = {};
  1805. this.apiLookupOpen = {};
  1806. var self = this;
  1807. this.$nextTick(function () {
  1808. self.currentAttributes.forEach(function (attr) {
  1809. if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
  1810. });
  1811. });
  1812. },
  1813. inputType(attrType) {
  1814. return ['number', 'date'].includes(attrType) ? attrType : 'text';
  1815. },
  1816. getApiOptions(name) {
  1817. return this.apiLookupOptions[name] || { fields: [], records: [] };
  1818. },
  1819. openApiLookup(name) {
  1820. var o = {}; o[name] = true;
  1821. this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o);
  1822. },
  1823. closeApiLookup(name) {
  1824. var o = {}; o[name] = false;
  1825. this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o);
  1826. },
  1827. getFilteredRecords(name, search) {
  1828. var records = this.getApiOptions(name).records;
  1829. if (!search) return records;
  1830. var term = String(search).toLowerCase();
  1831. return records.filter(function (rec) {
  1832. return rec._display.some(function (v) {
  1833. return String(v).toLowerCase().indexOf(term) !== -1;
  1834. });
  1835. });
  1836. },
  1837. selectApiOption(attr, rec) {
  1838. var newValues = Object.assign({}, this.attributeValues);
  1839. newValues[attr.name] = rec._primary;
  1840. if (attr.type === 'customer_lookup') {
  1841. // Auto-fill by attribute NAME: _row is name-keyed, db_auto_fill stores names
  1842. var names = (attr.db_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean);
  1843. names.forEach(function (name) {
  1844. var rowVal = rec._row[name];
  1845. if (rowVal !== undefined && rowVal !== null) {
  1846. newValues[name] = String(rowVal);
  1847. }
  1848. });
  1849. } else {
  1850. // api_lookup: auto-fill by alias
  1851. var autoFill = (attr.api_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean);
  1852. var attrs = this.currentAttributes;
  1853. autoFill.forEach(function (alias) {
  1854. var target = null;
  1855. for (var i = 0; i < attrs.length; i++) {
  1856. if (attrs[i].alias === alias) { target = attrs[i]; break; }
  1857. }
  1858. if (!target) return;
  1859. var rowVal = rec._row[alias];
  1860. if (rowVal !== undefined && rowVal !== null) {
  1861. newValues[target.name] = String(rowVal);
  1862. }
  1863. });
  1864. }
  1865. this.attributeValues = newValues;
  1866. this.closeApiLookup(attr.name);
  1867. },
  1868. fetchApiValue(attr) {
  1869. var self = this;
  1870. if (!attr.api_url) return;
  1871. var resolvedUrl = attr.api_url.replace(/\{alias\}/g, encodeURIComponent(attr.alias || ''));
  1872. var s = {}; s[attr.name] = 'loading';
  1873. var e = {}; e[attr.name] = '';
  1874. var o = {}; o[attr.name] = { fields: [], records: [] };
  1875. self.apiLookupState = Object.assign({}, self.apiLookupState, s);
  1876. self.apiLookupError = Object.assign({}, self.apiLookupError, e);
  1877. self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, o);
  1878. var matchFields = (attr.api_match_field || '')
  1879. .split(';')
  1880. .map(function (s) { return s.trim(); })
  1881. .filter(Boolean);
  1882. fetch('/api/proxy?url=' + encodeURIComponent(resolvedUrl))
  1883. .then(function (res) { return res.json(); })
  1884. .then(function (envelope) {
  1885. if (envelope.error) {
  1886. var se = {}; se[attr.name] = envelope.error;
  1887. var ss = {}; ss[attr.name] = 'error';
  1888. self.apiLookupError = Object.assign({}, self.apiLookupError, se);
  1889. self.apiLookupState = Object.assign({}, self.apiLookupState, ss);
  1890. return;
  1891. }
  1892. var body = envelope.body || '';
  1893. var result = { fields: matchFields.slice(), records: [] };
  1894. var seenRows = [];
  1895. function addRow(rawRow) {
  1896. if (seenRows.indexOf(rawRow) !== -1) return;
  1897. seenRows.push(rawRow);
  1898. var display = result.fields.map(function (f) {
  1899. var v = rawRow[f]; return (v !== undefined && v !== null) ? String(v) : '';
  1900. });
  1901. result.records.push({ _primary: display[0] || '', _display: display, _row: rawRow });
  1902. }
  1903. if (attr.api_format === 'xml') {
  1904. try {
  1905. var doc = new DOMParser().parseFromString(body, 'text/xml');
  1906. if (result.fields.length === 0) { result.fields = [doc.documentElement.tagName]; }
  1907. var firstField = result.fields[0];
  1908. var els = doc.getElementsByTagName(firstField);
  1909. for (var xi = 0; xi < els.length; xi++) {
  1910. var row = {};
  1911. var par = els[xi].parentNode;
  1912. if (par) {
  1913. for (var xc = 0; xc < par.childNodes.length; xc++) {
  1914. var cn = par.childNodes[xc];
  1915. if (cn.nodeType === 1) { row[cn.tagName] = cn.textContent.trim(); }
  1916. }
  1917. }
  1918. addRow(row);
  1919. }
  1920. } catch (e) { /* leave records empty */ }
  1921. } else {
  1922. try {
  1923. var parsed = JSON.parse(body);
  1924. if (result.fields.length > 0) {
  1925. _deepFindRows(parsed, result.fields[0]).forEach(function (hit) { addRow(hit.row); });
  1926. } else if (Array.isArray(parsed)) {
  1927. result.fields = ['Value'];
  1928. parsed.forEach(function (item) {
  1929. if (typeof item !== 'object') { addRow({ Value: String(item) }); }
  1930. });
  1931. } else if (typeof parsed === 'string' || typeof parsed === 'number' || typeof parsed === 'boolean') {
  1932. result.fields = ['Value'];
  1933. addRow({ Value: String(parsed) });
  1934. }
  1935. } catch (e) { /* leave records empty */ }
  1936. }
  1937. var oo = {}; oo[attr.name] = result;
  1938. var os = {}; os[attr.name] = 'idle';
  1939. self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, oo);
  1940. self.apiLookupState = Object.assign({}, self.apiLookupState, os);
  1941. })
  1942. .catch(function (err) {
  1943. console.error('[api-lookup] fetch failed:', err);
  1944. var ce = {}; ce[attr.name] = 'Network error — see browser console.';
  1945. var cs = {}; cs[attr.name] = 'error';
  1946. self.apiLookupError = Object.assign({}, self.apiLookupError, ce);
  1947. self.apiLookupState = Object.assign({}, self.apiLookupState, cs);
  1948. });
  1949. },
  1950. confirmDelete(event) {
  1951. if (confirm('Delete this customer? This cannot be undone.')) {
  1952. event.target.submit();
  1953. }
  1954. },
  1955. };
  1956. };
  1957. // Unsaved-changes guard — fires beforeunload warning when a .ct-form has been
  1958. // touched but not yet submitted. Delete forms and the logout form are excluded
  1959. // because they use different CSS classes and are intentional navigation.
  1960. (function () {
  1961. function initDirtyFormGuard() {
  1962. var forms = document.querySelectorAll('form.ct-form');
  1963. if (!forms.length) return;
  1964. var dirty = false;
  1965. function markDirty() { dirty = true; }
  1966. function markClean() { dirty = false; }
  1967. forms.forEach(function (form) {
  1968. form.addEventListener('input', markDirty);
  1969. form.addEventListener('change', markDirty);
  1970. form.addEventListener('submit', markClean);
  1971. });
  1972. window.addEventListener('beforeunload', function (e) {
  1973. if (!dirty) return;
  1974. e.preventDefault();
  1975. e.returnValue = '';
  1976. });
  1977. }
  1978. if (document.readyState === 'loading') {
  1979. document.addEventListener('DOMContentLoaded', initDirtyFormGuard);
  1980. } else {
  1981. initDirtyFormGuard();
  1982. }
  1983. }());

Powered by TurnKey Linux.