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.

2241 Zeilen
84KB

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

Powered by TurnKey Linux.