Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

2582 rindas
96KB

  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. function _tabulatorPersistenceKey(key) {
  47. return 'ct.tabulator.' + key;
  48. }
  49. // Builds a stable Tabulator field name for a dynamic attribute column, derived from the
  50. // attribute's name rather than its position. This keeps persisted column order, width,
  51. // visibility, and filters tied to the same attribute across page loads, even when the
  52. // set/order of attributes discovered from the result rows changes.
  53. function _attributeFieldKey(name, usedFields) {
  54. let slug = String(name).toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
  55. if (slug === '') {
  56. slug = 'field';
  57. }
  58. const base = 'attr__' + slug;
  59. let key = base;
  60. let suffix = 2;
  61. while (usedFields.has(key)) {
  62. key = base + '_' + suffix;
  63. suffix++;
  64. }
  65. usedFields.add(key);
  66. return key;
  67. }
  68. // Discovers the unique set of dynamic attributes across a list of rows, assigning each
  69. // a stable `field` name (see _attributeFieldKey) for use as a Tabulator column field.
  70. function _discoverAttributeColumns(rows, typeAttributesField, normalizeFn) {
  71. const attributes = [];
  72. const usedFields = new Set();
  73. rows.forEach((row) => {
  74. normalizeFn(row[typeAttributesField] || []).forEach((attr) => {
  75. if (!attributes.some((existing) => existing.name === attr.name)) {
  76. attributes.push({ ...attr, field: _attributeFieldKey(attr.name, usedFields) });
  77. }
  78. });
  79. Object.keys(row.attribute_values || {}).forEach((name) => {
  80. if (!attributes.some((existing) => existing.name === name)) {
  81. attributes.push({ name: name, type: 'text', order: attributes.length + 1, field: _attributeFieldKey(name, usedFields) });
  82. }
  83. });
  84. });
  85. return attributes;
  86. }
  87. function _tabulatorBaseOptions(persistenceKey) {
  88. return {
  89. persistence: {
  90. columns: true,
  91. sort: true,
  92. filter: true,
  93. page: {
  94. size: true,
  95. page: true,
  96. },
  97. },
  98. persistenceID: persistenceKey,
  99. };
  100. }
  101. function _clearTabulatorPersistence(persistenceKey) {
  102. try {
  103. window.localStorage.removeItem(_tabulatorPersistenceKey(persistenceKey + '-columns'));
  104. window.localStorage.removeItem(_tabulatorPersistenceKey(persistenceKey + '-sort'));
  105. window.localStorage.removeItem(_tabulatorPersistenceKey(persistenceKey + '-filter'));
  106. window.localStorage.removeItem(_tabulatorPersistenceKey(persistenceKey + '-page'));
  107. } catch (error) {
  108. }
  109. }
  110. function _tabulatorVisibleColumns(table) {
  111. if (!table || typeof table.getColumns !== 'function') {
  112. return [];
  113. }
  114. return table.getColumns()
  115. .map((column) => {
  116. const definition = typeof column.getDefinition === 'function' ? column.getDefinition() : null;
  117. if (!definition || !definition.field || definition.field === 'edit_url') {
  118. return null;
  119. }
  120. const element = typeof column.getElement === 'function' ? column.getElement() : null;
  121. const isVisible = !element || element.offsetParent !== null;
  122. if (!isVisible) {
  123. return null;
  124. }
  125. return {
  126. field: definition.field,
  127. title: definition.title || definition.field,
  128. };
  129. })
  130. .filter((column) => column !== null);
  131. }
  132. function _downloadBlob(blob, filename) {
  133. const url = window.URL.createObjectURL(blob);
  134. const link = document.createElement('a');
  135. link.href = url;
  136. link.download = filename;
  137. document.body.appendChild(link);
  138. link.click();
  139. link.remove();
  140. window.setTimeout(() => window.URL.revokeObjectURL(url), 0);
  141. }
  142. async function _exportTabulatorCsv(url, table, filename) {
  143. if (!table) {
  144. throw new Error('The table is not ready yet.');
  145. }
  146. const payload = {
  147. columns: _tabulatorVisibleColumns(table),
  148. rows: typeof table.getData === 'function' ? table.getData('active') : [],
  149. };
  150. const response = await fetch(url, {
  151. method: 'POST',
  152. headers: {
  153. 'Accept': 'text/csv',
  154. 'Content-Type': 'application/json',
  155. 'X-CSRF-TOKEN': window.__csrf || '',
  156. },
  157. body: JSON.stringify(payload),
  158. });
  159. if (!response.ok) {
  160. throw new Error('Unable to export CSV.');
  161. }
  162. const blob = await response.blob();
  163. const disposition = response.headers.get('Content-Disposition') || '';
  164. const match = disposition.match(/filename="?([^";]+)"?/i);
  165. _downloadBlob(blob, match ? match[1] : filename);
  166. }
  167. // ── Campaign Type ─────────────────────────────────────────────────────────────
  168. window.campaignTypeTable = function () {
  169. return {
  170. table: null,
  171. init() {
  172. this.initTable();
  173. },
  174. initTable() {
  175. const el = document.getElementById('campaign-type-table');
  176. if (!el || typeof Tabulator === 'undefined') {
  177. return;
  178. }
  179. this.table = new Tabulator(el, {
  180. ajaxURL: '/campaign-types/data',
  181. layout: 'fitColumns',
  182. responsiveLayout: 'collapse',
  183. pagination: true,
  184. paginationMode: 'local',
  185. paginationSize: 10,
  186. paginationSizeSelector: PAGE_SIZES,
  187. movableColumns: true,
  188. placeholder: 'No campaign types found.',
  189. initialSort: [{ column: 'name', dir: 'asc' }],
  190. columns: [
  191. {
  192. title: 'Actions',
  193. field: 'id',
  194. width: 160,
  195. hozAlign: 'center',
  196. headerSort: false,
  197. formatter: function (cell) {
  198. const id = cell.getValue();
  199. return '<a href="/campaign-types/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' +
  200. '<button onclick="window.deleteCampaignType(' + id + ')" class="button button-danger button-sm">Delete</button>';
  201. },
  202. },
  203. { title: 'Name', field: 'name', minWidth: 200 },
  204. {
  205. title: 'Attributes',
  206. field: 'attributes_summary',
  207. minWidth: 240,
  208. formatter: function (cell) {
  209. const v = cell.getValue();
  210. return v ? '<span class="attr-summary">' + _escapeHtml(v) + '</span>'
  211. : '<span class="attr-empty">&mdash;</span>';
  212. },
  213. },
  214. { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 },
  215. { title: 'Created', field: 'created_at', minWidth: 160 },
  216. ],
  217. });
  218. },
  219. reloadTable() {
  220. if (!this.table) {
  221. this.initTable();
  222. return;
  223. }
  224. this.table.setData('/campaign-types/data');
  225. },
  226. };
  227. };
  228. window.deleteCampaignType = function (id) {
  229. if (!confirm('Delete this campaign type? This cannot be undone.')) {
  230. return;
  231. }
  232. _postDelete('/campaign-types/' + id + '/delete');
  233. };
  234. window.campaignTypeForm = function (initialAttributes) {
  235. return {
  236. attributes: Array.isArray(initialAttributes) ? initialAttributes : [],
  237. dragIndex: null,
  238. dragOverIndex: null,
  239. addAttribute() {
  240. this.attributes.push({ name: '', type: 'text', order: this.attributes.length + 1 });
  241. },
  242. removeAttribute(index) {
  243. this.attributes.splice(index, 1);
  244. this.renumberOrder();
  245. },
  246. renumberOrder() {
  247. this.attributes.forEach(function (attr, i) { attr.order = i + 1; });
  248. },
  249. dragStart(event, index) {
  250. this.dragIndex = index;
  251. event.dataTransfer.effectAllowed = 'move';
  252. },
  253. dragOver(event, index) {
  254. this.dragOverIndex = index;
  255. },
  256. drop(event, index) {
  257. if (this.dragIndex !== null && this.dragIndex !== index) {
  258. var moved = this.attributes.splice(this.dragIndex, 1)[0];
  259. this.attributes.splice(index, 0, moved);
  260. this.renumberOrder();
  261. }
  262. this.dragIndex = null;
  263. this.dragOverIndex = null;
  264. },
  265. dragEnd() {
  266. this.dragIndex = null;
  267. this.dragOverIndex = null;
  268. },
  269. confirmDelete(event) {
  270. if (confirm('Delete this campaign type? This cannot be undone.')) {
  271. event.target.submit();
  272. }
  273. },
  274. };
  275. };
  276. // ── Campaign ──────────────────────────────────────────────────────────────────
  277. window.campaignTable = function () {
  278. return {
  279. table: null,
  280. jobsTable: null,
  281. isLoading: false,
  282. isJobsLoading: false,
  283. errorMessage: '',
  284. jobsErrorMessage: '',
  285. selectedCampaignId: null,
  286. selectedCampaignTitle: '',
  287. init() {
  288. this.loadTable();
  289. },
  290. async loadTable() {
  291. const el = document.getElementById('campaign-table');
  292. if (!el || typeof Tabulator === 'undefined' || this.isLoading) {
  293. return;
  294. }
  295. this.isLoading = true;
  296. this.errorMessage = '';
  297. try {
  298. const response = await fetch('/campaigns/data', {
  299. headers: { Accept: 'application/json' },
  300. });
  301. if (!response.ok) {
  302. throw new Error('Unable to load campaigns.');
  303. }
  304. const rows = await response.json();
  305. const campaignRows = Array.isArray(rows) ? rows : [];
  306. const attributes = this.attributeColumnsForRows(campaignRows);
  307. const tableRows = this.formatRows(campaignRows, attributes);
  308. const columns = this.columnsForAttributes(attributes);
  309. if (!this.table) {
  310. this.table = new Tabulator(el, {
  311. data: tableRows,
  312. layout: 'fitColumns',
  313. responsiveLayout: 'collapse',
  314. pagination: true,
  315. paginationMode: 'local',
  316. paginationSize: 10,
  317. paginationSizeSelector: PAGE_SIZES,
  318. movableColumns: true,
  319. placeholder: 'No campaigns found.',
  320. initialSort: [{ column: 'campaign_type_name', dir: 'asc' }],
  321. columns: columns,
  322. });
  323. this.table.on('rowClick', (event, row) => this.goToCampaignJobs(event, row));
  324. } else {
  325. this.table.setColumns(columns);
  326. this.table.setData(tableRows);
  327. }
  328. } catch (error) {
  329. this.errorMessage = error.message || 'Unable to load campaigns.';
  330. } finally {
  331. this.isLoading = false;
  332. }
  333. },
  334. attributeColumnsForRows(rows) {
  335. const attributes = [];
  336. rows.forEach((row) => {
  337. this.normalizeAttributes(row.campaign_type_attributes || []).forEach((attr) => {
  338. if (!attributes.some((existing) => existing.name === attr.name)) {
  339. attributes.push(attr);
  340. }
  341. });
  342. Object.keys(row.attribute_values || {}).forEach((name) => {
  343. if (!attributes.some((existing) => existing.name === name)) {
  344. attributes.push({ name: name, type: 'text', order: attributes.length + 1 });
  345. }
  346. });
  347. });
  348. return attributes;
  349. },
  350. normalizeAttributes(attributes) {
  351. return attributes
  352. .filter((attr) => attr && attr.name)
  353. .slice()
  354. .sort((a, b) => (a.order || 0) - (b.order || 0));
  355. },
  356. formatRows(rows, attributes) {
  357. return rows.map((row) => {
  358. const attributeValues = row.attribute_values || {};
  359. const tableRow = {
  360. id: row.id,
  361. campaign_type_name: row.campaign_type_name || '',
  362. created_at: row.created_at || '',
  363. };
  364. attributes.forEach((attr, index) => {
  365. tableRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? '');
  366. });
  367. return tableRow;
  368. });
  369. },
  370. formatAttributeValue(value) {
  371. if (value === null || value === undefined) {
  372. return '';
  373. }
  374. if (Array.isArray(value) || typeof value === 'object') {
  375. return JSON.stringify(value);
  376. }
  377. return String(value);
  378. },
  379. columnsForAttributes(attributes) {
  380. const columns = [
  381. {
  382. title: 'Actions',
  383. field: 'id',
  384. width: 230,
  385. hozAlign: 'center',
  386. headerSort: false,
  387. formatter: function (cell) {
  388. const id = cell.getValue();
  389. return '<a href="/campaigns/' + id + '/jobs" class="button button-primary button-sm">Jobs</a> ' +
  390. '<a href="/campaigns/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' +
  391. '<button onclick="window.deleteCampaign(' + id + ')" class="button button-danger button-sm">Delete</button>';
  392. },
  393. },
  394. {
  395. title: 'Campaign Type',
  396. field: 'campaign_type_name',
  397. minWidth: 160,
  398. headerFilter: 'input',
  399. },
  400. ];
  401. attributes.forEach((attr, index) => {
  402. columns.push({
  403. title: attr.name,
  404. field: 'attr_' + index,
  405. minWidth: 150,
  406. headerFilter: 'input',
  407. formatter: function (cell) {
  408. const value = cell.getValue();
  409. return value ? _escapeHtml(value) : '<span class="attr-empty">&mdash;</span>';
  410. },
  411. });
  412. });
  413. columns.push(
  414. { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' }
  415. );
  416. return columns;
  417. },
  418. goToCampaignJobs(event, row) {
  419. const target = event.target;
  420. if (target instanceof Element && target.closest('a, button')) {
  421. return;
  422. }
  423. window.location.href = '/campaigns/' + encodeURIComponent(row.getData().id) + '/jobs';
  424. },
  425. reloadTable() {
  426. this.loadTable();
  427. },
  428. openCampaignJobs(campaign) {
  429. this.selectedCampaignId = campaign.id;
  430. this.selectedCampaignTitle = 'Campaign #' + campaign.id + ' - ' + campaign.campaign_type_name;
  431. this.$nextTick(() => this.loadJobsTable());
  432. },
  433. async reloadJobsTable() {
  434. if (!this.selectedCampaignId) {
  435. return;
  436. }
  437. await this.loadJobsTable();
  438. },
  439. closeJobsTable() {
  440. this.selectedCampaignId = null;
  441. this.selectedCampaignTitle = '';
  442. this.jobsErrorMessage = '';
  443. if (this.jobsTable && typeof this.jobsTable.destroy === 'function') {
  444. this.jobsTable.destroy();
  445. }
  446. this.jobsTable = null;
  447. },
  448. async loadJobsTable() {
  449. const el = document.getElementById('campaign-jobs-drilldown-table');
  450. if (!el || typeof Tabulator === 'undefined' || this.isJobsLoading) {
  451. return;
  452. }
  453. this.isJobsLoading = true;
  454. this.jobsErrorMessage = '';
  455. try {
  456. const response = await fetch('/campaigns/' + encodeURIComponent(this.selectedCampaignId) + '/jobs/data', {
  457. headers: { Accept: 'application/json' },
  458. });
  459. if (!response.ok) {
  460. throw new Error('Unable to load campaign jobs.');
  461. }
  462. const rows = await response.json();
  463. const jobRows = Array.isArray(rows) ? rows : [];
  464. const attributes = this.jobAttributeColumnsForRows(jobRows);
  465. const tableRows = this.formatJobRows(jobRows, attributes);
  466. const columns = this.jobColumnsForAttributes(attributes);
  467. if (!this.jobsTable) {
  468. this.jobsTable = new Tabulator(el, {
  469. data: tableRows,
  470. layout: 'fitColumns',
  471. responsiveLayout: 'collapse',
  472. pagination: true,
  473. paginationMode: 'local',
  474. paginationSize: 10,
  475. paginationSizeSelector: PAGE_SIZES,
  476. movableColumns: true,
  477. placeholder: 'No jobs found for this campaign.',
  478. initialSort: [{ column: 'job_type_name', dir: 'asc' }],
  479. columns: columns,
  480. ..._tabulatorBaseOptions(this.persistenceKey),
  481. });
  482. } else {
  483. this.jobsTable.setColumns(columns);
  484. this.jobsTable.setData(tableRows);
  485. }
  486. } catch (error) {
  487. this.jobsErrorMessage = error.message || 'Unable to load campaign jobs.';
  488. } finally {
  489. this.isJobsLoading = false;
  490. }
  491. },
  492. jobAttributeColumnsForRows(rows) {
  493. return _discoverAttributeColumns(rows, 'job_type_attributes', this.normalizeAttributes);
  494. },
  495. formatJobRows(rows, attributes) {
  496. return rows.map((row) => {
  497. const attributeValues = row.attribute_values || {};
  498. const tableRow = {
  499. id: row.id,
  500. campaign_id: row.campaign_id || '',
  501. job_type_id: row.job_type_id || '',
  502. job_type_name: row.job_type_name || '',
  503. created_at: row.created_at || '',
  504. updated_at: row.updated_at || '',
  505. edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit',
  506. };
  507. attributes.forEach((attr) => {
  508. tableRow[attr.field] = this.formatAttributeValue(attributeValues[attr.name] ?? '');
  509. });
  510. return tableRow;
  511. });
  512. },
  513. jobColumnsForAttributes(attributes) {
  514. const columns = [
  515. {
  516. title: 'Actions',
  517. field: 'edit_url',
  518. width: 90,
  519. hozAlign: 'center',
  520. headerSort: false,
  521. formatter: function (cell) {
  522. return '<a href="' + _escapeHtml(cell.getValue()) + '" class="button button-secondary button-sm">Edit</a>';
  523. },
  524. },
  525. { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' },
  526. { title: 'Campaign ID', field: 'campaign_id', width: 120, hozAlign: 'center', headerFilter: 'input' },
  527. { title: 'Job Type ID', field: 'job_type_id', width: 120, hozAlign: 'center', headerFilter: 'input' },
  528. { title: 'Job Type', field: 'job_type_name', minWidth: 160, headerFilter: 'input' },
  529. ];
  530. attributes.forEach((attr) => {
  531. columns.push({
  532. title: attr.name,
  533. field: attr.field,
  534. minWidth: 150,
  535. headerFilter: 'input',
  536. formatter: function (cell) {
  537. const value = cell.getValue();
  538. return value ? _escapeHtml(value) : '<span class="attr-empty">&mdash;</span>';
  539. },
  540. });
  541. });
  542. columns.push(
  543. { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' },
  544. { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' }
  545. );
  546. return columns;
  547. },
  548. };
  549. };
  550. window.deleteCampaign = function (id) {
  551. if (!confirm('Delete this campaign? This cannot be undone.')) {
  552. return;
  553. }
  554. _postDelete('/campaigns/' + id + '/delete');
  555. };
  556. window.campaignJobsPageTable = function (campaignId, jobTypes) {
  557. return {
  558. table: null,
  559. jobTypes: Array.isArray(jobTypes) ? jobTypes : [],
  560. isLoading: false,
  561. configMessage: '',
  562. isConnecting: false,
  563. isImporting: false,
  564. errorMessage: '',
  565. importSheetUrl: '',
  566. sheets: [],
  567. selectedSheetGid: '',
  568. selectedImportJobTypeId: '0',
  569. importMessage: '',
  570. importErrorMessage: '',
  571. // File upload state
  572. importSource: 'sheets',
  573. fileSelected: false,
  574. fileTempName: '',
  575. fileSheets: [],
  576. selectedFileSheetGid: '',
  577. selectedFileJobTypeId: '0',
  578. isLoadingFile: false,
  579. isImportingFile: false,
  580. persistenceKey: 'campaign-jobs-page-' + campaignId,
  581. init() {
  582. this.loadTable();
  583. },
  584. dataUrl() {
  585. return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/data';
  586. },
  587. sheetsUrl() {
  588. return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import/sheets';
  589. },
  590. importUrl() {
  591. return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import';
  592. },
  593. async loadTable() {
  594. const el = document.getElementById('campaign-jobs-page-table');
  595. if (!el || typeof Tabulator === 'undefined' || this.isLoading) {
  596. return;
  597. }
  598. this.isLoading = true;
  599. this.errorMessage = '';
  600. try {
  601. const response = await fetch(this.dataUrl(), {
  602. headers: { Accept: 'application/json' },
  603. });
  604. if (!response.ok) {
  605. throw new Error('Unable to load campaign jobs.');
  606. }
  607. const rows = await response.json();
  608. const jobRows = Array.isArray(rows) ? rows : [];
  609. const attributes = this.attributeColumnsForRows(jobRows);
  610. const tableRows = this.formatRows(jobRows, attributes);
  611. const columns = this.columnsForAttributes(attributes);
  612. if (!this.table) {
  613. this.table = new Tabulator(el, {
  614. data: tableRows,
  615. layout: 'fitData',
  616. maxHeight: '65vh',
  617. pagination: true,
  618. paginationMode: 'local',
  619. paginationSize: 10,
  620. paginationSizeSelector: PAGE_SIZES,
  621. movableColumns: true,
  622. placeholder: 'No jobs found for this campaign.',
  623. initialSort: [{ column: 'job_type_name', dir: 'asc' }],
  624. columns: columns,
  625. });
  626. } else {
  627. this.table.setColumns(columns);
  628. this.table.setData(tableRows);
  629. }
  630. } catch (error) {
  631. this.errorMessage = error.message || 'Unable to load campaign jobs.';
  632. } finally {
  633. this.isLoading = false;
  634. }
  635. },
  636. attributeColumnsForRows(rows) {
  637. const attributes = [];
  638. rows.forEach((row) => {
  639. this.normalizeAttributes(row.job_type_attributes || []).forEach((attr) => {
  640. if (!attributes.some((existing) => existing.name === attr.name)) {
  641. attributes.push(attr);
  642. }
  643. });
  644. Object.keys(row.attribute_values || {}).forEach((name) => {
  645. if (!attributes.some((existing) => existing.name === name)) {
  646. attributes.push({ name: name, type: 'text', order: attributes.length + 1 });
  647. }
  648. });
  649. });
  650. return attributes;
  651. },
  652. normalizeAttributes(attributes) {
  653. return attributes
  654. .filter((attr) => attr && attr.name)
  655. .slice()
  656. .sort((a, b) => (a.order || 0) - (b.order || 0));
  657. },
  658. formatRows(rows, attributes) {
  659. return rows.map((row) => {
  660. const attributeValues = row.attribute_values || {};
  661. const tableRow = {
  662. id: row.id,
  663. campaign_id: row.campaign_id || '',
  664. campaign_type_name: row.campaign_type_name || '',
  665. job_type_id: row.job_type_id || '',
  666. job_type_name: row.job_type_name || '',
  667. created_at: row.created_at || '',
  668. updated_at: row.updated_at || '',
  669. edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit',
  670. };
  671. attributes.forEach((attr, index) => {
  672. tableRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? '');
  673. });
  674. return tableRow;
  675. });
  676. },
  677. formatAttributeValue(value) {
  678. if (value === null || value === undefined) {
  679. return '';
  680. }
  681. if (Array.isArray(value) || typeof value === 'object') {
  682. return JSON.stringify(value);
  683. }
  684. return String(value);
  685. },
  686. columnsForAttributes(attributes) {
  687. const columns = [
  688. {
  689. title: 'Actions',
  690. field: 'edit_url',
  691. width: 90,
  692. hozAlign: 'center',
  693. headerSort: false,
  694. formatter: function (cell) {
  695. return '<a href="' + _escapeHtml(cell.getValue()) + '" class="button button-secondary button-sm">Edit</a>';
  696. },
  697. },
  698. { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' },
  699. { title: 'Campaign ID', field: 'campaign_id', width: 120, hozAlign: 'center', headerFilter: 'input' },
  700. { title: 'Campaign Type', field: 'campaign_type_name', minWidth: 160, headerFilter: 'input' },
  701. { title: 'Job Type ID', field: 'job_type_id', width: 120, hozAlign: 'center', headerFilter: 'input' },
  702. { title: 'Job Type', field: 'job_type_name', minWidth: 160, headerFilter: 'input' },
  703. ];
  704. attributes.forEach((attr, index) => {
  705. columns.push({
  706. title: attr.name,
  707. field: 'attr_' + index,
  708. minWidth: 150,
  709. headerFilter: 'input',
  710. formatter: function (cell) {
  711. const value = cell.getValue();
  712. return value ? _escapeHtml(value) : '<span class="attr-empty">&mdash;</span>';
  713. },
  714. });
  715. });
  716. columns.push(
  717. { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' },
  718. { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' }
  719. );
  720. return columns;
  721. },
  722. reloadTable() {
  723. this.loadTable();
  724. },
  725. async exportCsv() {
  726. try {
  727. await _exportTabulatorCsv('/campaigns/' + encodeURIComponent(campaignId) + '/jobs/export', this.table, 'campaign-jobs-export.csv');
  728. } catch (error) {
  729. this.errorMessage = error.message || 'Unable to export CSV.';
  730. }
  731. },
  732. async connectGoogleSheet() {
  733. this.isConnecting = true;
  734. this.importMessage = '';
  735. this.importErrorMessage = '';
  736. this.sheets = [];
  737. this.selectedSheetGid = '';
  738. try {
  739. const data = await this.postImportForm(this.sheetsUrl(), {
  740. sheet_url: this.importSheetUrl,
  741. });
  742. this.sheets = Array.isArray(data.sheets) ? data.sheets : [];
  743. if (this.sheets.length > 0) {
  744. this.selectedSheetGid = this.sheets[0].gid;
  745. this.importMessage = 'Connected. Select a sheet and job type to import.';
  746. } else {
  747. this.importErrorMessage = 'No sheets were found in that Google Sheets file.';
  748. }
  749. } catch (error) {
  750. this.importErrorMessage = error.message || 'Unable to connect to Google Sheets.';
  751. } finally {
  752. this.isConnecting = false;
  753. }
  754. },
  755. async importGoogleSheet() {
  756. this.isImporting = true;
  757. this.importMessage = '';
  758. this.importErrorMessage = '';
  759. try {
  760. const data = await this.postImportForm(this.importUrl(), {
  761. sheet_url: this.importSheetUrl,
  762. sheet_gid: this.selectedSheetGid,
  763. job_type_id: this.selectedImportJobTypeId,
  764. });
  765. const matched = Array.isArray(data.matched_attributes) ? data.matched_attributes.join(', ') : '';
  766. this.importMessage = 'Imported ' + data.imported + ' jobs. Skipped ' + data.skipped + ' empty rows.' +
  767. (matched ? ' Matched: ' + matched + '.' : '');
  768. await this.loadTable();
  769. } catch (error) {
  770. this.importErrorMessage = error.message || 'Unable to import Google Sheet.';
  771. } finally {
  772. this.isImporting = false;
  773. }
  774. },
  775. async postImportForm(url, fields) {
  776. const body = new URLSearchParams();
  777. body.set('_token', window.__csrf || '');
  778. Object.keys(fields).forEach((key) => {
  779. body.set(key, fields[key] || '');
  780. });
  781. const response = await fetch(url, {
  782. method: 'POST',
  783. headers: {
  784. Accept: 'application/json',
  785. 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
  786. },
  787. body: body.toString(),
  788. });
  789. const data = await response.json().catch(() => ({}));
  790. if (!response.ok) {
  791. throw new Error(data.error || 'Import failed.');
  792. }
  793. return data;
  794. },
  795. // ── File upload methods ───────────────────────────────────────────────
  796. onFileSelect(event) {
  797. this.fileSelected = event.target.files && event.target.files.length > 0;
  798. this.fileTempName = '';
  799. this.fileSheets = [];
  800. this.selectedFileSheetGid = '';
  801. this.importMessage = '';
  802. this.importErrorMessage = '';
  803. },
  804. async loadFileSheets() {
  805. this.isLoadingFile = true;
  806. this.importMessage = '';
  807. this.importErrorMessage = '';
  808. this.fileTempName = '';
  809. this.fileSheets = [];
  810. this.selectedFileSheetGid = '';
  811. try {
  812. const fileInput = this.$refs.fileInput;
  813. if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
  814. throw new Error('No file selected.');
  815. }
  816. const form = new FormData();
  817. form.set('_token', window.__csrf || '');
  818. form.set('import_file', fileInput.files[0]);
  819. const url = '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import/file/sheets';
  820. const response = await fetch(url, {
  821. method: 'POST',
  822. headers: { Accept: 'application/json' },
  823. body: form,
  824. });
  825. const data = await response.json().catch(() => ({}));
  826. if (!response.ok) {
  827. throw new Error(data.error || 'Could not read the file.');
  828. }
  829. this.fileTempName = data.temp_file || '';
  830. this.fileSheets = Array.isArray(data.sheets) ? data.sheets : [];
  831. if (this.fileSheets.length > 0) {
  832. this.selectedFileSheetGid = this.fileSheets[0].gid;
  833. this.importMessage = 'File loaded. Select a sheet and job type to import.';
  834. } else {
  835. this.importErrorMessage = 'No sheets were found in the uploaded file.';
  836. }
  837. } catch (error) {
  838. this.importErrorMessage = error.message || 'Could not read the file.';
  839. } finally {
  840. this.isLoadingFile = false;
  841. }
  842. },
  843. async importFile() {
  844. this.isImportingFile = true;
  845. this.importMessage = '';
  846. this.importErrorMessage = '';
  847. try {
  848. const url = '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import/file';
  849. const data = await this.postImportForm(url, {
  850. temp_file: this.fileTempName,
  851. sheet_gid: this.selectedFileSheetGid,
  852. job_type_id: this.selectedFileJobTypeId,
  853. });
  854. const matched = Array.isArray(data.matched_attributes) ? data.matched_attributes.join(', ') : '';
  855. this.importMessage = 'Imported ' + data.imported + ' jobs. Skipped ' + data.skipped + ' empty rows.' +
  856. (matched ? ' Matched: ' + matched + '.' : '');
  857. this.fileTempName = '';
  858. await this.loadTable();
  859. } catch (error) {
  860. this.importErrorMessage = error.message || 'Import failed.';
  861. } finally {
  862. this.isImportingFile = false;
  863. }
  864. },
  865. };
  866. };
  867. window.campaignForm = function (types, initialTypeId, initialValues) {
  868. return {
  869. types: types,
  870. selectedTypeId: String(initialTypeId || ''),
  871. attributeValues: Object.assign({}, initialValues || {}),
  872. get currentType() {
  873. var id = this.selectedTypeId;
  874. if (!id) return null;
  875. return this.types.find(function (t) { return String(t.id) === String(id); }) || null;
  876. },
  877. get currentAttributes() {
  878. if (!this.currentType) return [];
  879. return this.currentType.attributes.slice().sort(function (a, b) {
  880. return (a.order || 0) - (b.order || 0);
  881. });
  882. },
  883. onTypeChange() {
  884. this.attributeValues = {};
  885. },
  886. inputType(attrType) {
  887. return ['number', 'date'].includes(attrType) ? attrType : 'text';
  888. },
  889. confirmDelete(event) {
  890. if (confirm('Delete this campaign? This cannot be undone.')) {
  891. event.target.submit();
  892. }
  893. },
  894. };
  895. };
  896. // Campaign Jobs
  897. window.campaignJobsTable = function (campaignId) {
  898. return {
  899. tables: {},
  900. groups: [],
  901. isVisible: false,
  902. isLoading: false,
  903. hasLoaded: false,
  904. errorMessage: '',
  905. dataUrl() {
  906. return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/data';
  907. },
  908. async showTable() {
  909. this.isVisible = true;
  910. if (!this.hasLoaded) {
  911. await this.loadGroups();
  912. }
  913. },
  914. hideTable() {
  915. this.isVisible = false;
  916. },
  917. async reloadTable() {
  918. this.isVisible = true;
  919. await this.loadGroups();
  920. },
  921. async loadGroups() {
  922. this.isLoading = true;
  923. this.errorMessage = '';
  924. this.destroyTables();
  925. try {
  926. const response = await fetch(this.dataUrl(), {
  927. headers: { Accept: 'application/json' },
  928. });
  929. if (!response.ok) {
  930. throw new Error('Unable to load campaign jobs.');
  931. }
  932. const rows = await response.json();
  933. this.groups = this.groupRows(Array.isArray(rows) ? rows : []);
  934. this.hasLoaded = true;
  935. this.$nextTick(() => this.initTables());
  936. } catch (error) {
  937. this.groups = [];
  938. this.errorMessage = error.message || 'Unable to load campaign jobs.';
  939. } finally {
  940. this.isLoading = false;
  941. }
  942. },
  943. groupRows(rows) {
  944. const groups = {};
  945. rows.forEach((row) => {
  946. const id = String(row.job_type_id || 0);
  947. if (!groups[id]) {
  948. groups[id] = {
  949. id: id,
  950. elementId: 'campaign-jobs-table-' + id,
  951. name: row.job_type_name || 'Job Type #' + id,
  952. attributes: this.normalizeAttributes(row.job_type_attributes || []),
  953. rows: [],
  954. };
  955. }
  956. const attributeValues = row.attribute_values || {};
  957. this.ensureAttributeColumns(groups[id], attributeValues);
  958. const gridRow = {
  959. id: row.id,
  960. created_at: row.created_at || '',
  961. edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit',
  962. };
  963. groups[id].attributes.forEach((attr, index) => {
  964. gridRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? '');
  965. });
  966. groups[id].rows.push(gridRow);
  967. });
  968. return Object.values(groups).sort((a, b) => a.name.localeCompare(b.name));
  969. },
  970. ensureAttributeColumns(group, attributeValues) {
  971. Object.keys(attributeValues).forEach((name) => {
  972. const exists = group.attributes.some((attr) => attr.name === name);
  973. if (!exists) {
  974. group.attributes.push({
  975. name: name,
  976. type: 'text',
  977. order: group.attributes.length + 1,
  978. });
  979. }
  980. });
  981. },
  982. normalizeAttributes(attributes) {
  983. return attributes
  984. .filter((attr) => attr && attr.name)
  985. .slice()
  986. .sort((a, b) => (a.order || 0) - (b.order || 0));
  987. },
  988. formatAttributeValue(value) {
  989. if (value === null || value === undefined) {
  990. return '';
  991. }
  992. if (Array.isArray(value) || typeof value === 'object') {
  993. return JSON.stringify(value);
  994. }
  995. return String(value);
  996. },
  997. initTables() {
  998. if (typeof Tabulator === 'undefined') {
  999. return;
  1000. }
  1001. this.groups.forEach((group) => {
  1002. const el = document.getElementById(group.elementId);
  1003. if (!el || this.tables[group.id]) {
  1004. return;
  1005. }
  1006. this.tables[group.id] = new Tabulator(el, {
  1007. data: group.rows,
  1008. layout: 'fitColumns',
  1009. responsiveLayout: 'collapse',
  1010. pagination: true,
  1011. paginationMode: 'local',
  1012. paginationSize: 5,
  1013. paginationSizeSelector: PAGE_SIZES_SM,
  1014. movableColumns: true,
  1015. placeholder: 'No jobs found for this job type.',
  1016. initialSort: [{ column: 'created_at', dir: 'desc' }],
  1017. columns: this.columnsForGroup(group),
  1018. });
  1019. });
  1020. },
  1021. columnsForGroup(group) {
  1022. const actions = {
  1023. title: 'Actions',
  1024. field: 'edit_url',
  1025. width: 90,
  1026. hozAlign: 'center',
  1027. headerSort: false,
  1028. formatter: function (cell) {
  1029. return '<a href="' + _escapeHtml(cell.getValue()) + '" class="button button-secondary button-sm">Edit</a>';
  1030. },
  1031. };
  1032. const attrColumns = group.attributes.map((attr, index) => ({
  1033. title: attr.name,
  1034. field: 'attr_' + index,
  1035. minWidth: 150,
  1036. formatter: function (cell) {
  1037. const value = cell.getValue();
  1038. return value ? _escapeHtml(value) : '<span class="attr-empty">&mdash;</span>';
  1039. },
  1040. }));
  1041. if (attrColumns.length === 0) {
  1042. attrColumns.push({ title: 'Job ID', field: 'id', width: 90, hozAlign: 'center' });
  1043. }
  1044. return [actions, ...attrColumns, { title: 'Created', field: 'created_at', minWidth: 160 }];
  1045. },
  1046. destroyTables() {
  1047. Object.values(this.tables).forEach((table) => {
  1048. if (table && typeof table.destroy === 'function') {
  1049. table.destroy();
  1050. }
  1051. });
  1052. this.tables = {};
  1053. },
  1054. };
  1055. };
  1056. // Job Type
  1057. window.jobTypeTable = function () {
  1058. return {
  1059. table: null,
  1060. init() {
  1061. this.initTable();
  1062. },
  1063. initTable() {
  1064. const el = document.getElementById('job-type-table');
  1065. if (!el || typeof Tabulator === 'undefined') {
  1066. return;
  1067. }
  1068. this.table = new Tabulator(el, {
  1069. ajaxURL: '/job-types/data',
  1070. layout: 'fitColumns',
  1071. responsiveLayout: 'collapse',
  1072. pagination: true,
  1073. paginationMode: 'local',
  1074. paginationSize: 10,
  1075. paginationSizeSelector: PAGE_SIZES,
  1076. movableColumns: true,
  1077. placeholder: 'No job types found.',
  1078. initialSort: [{ column: 'name', dir: 'asc' }],
  1079. columns: [
  1080. {
  1081. title: 'Actions',
  1082. field: 'id',
  1083. width: 160,
  1084. hozAlign: 'center',
  1085. headerSort: false,
  1086. formatter: function (cell) {
  1087. const id = cell.getValue();
  1088. return '<a href="/job-types/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' +
  1089. '<button onclick="window.deleteJobType(' + id + ')" class="button button-danger button-sm">Delete</button>';
  1090. },
  1091. },
  1092. { title: 'Name', field: 'name', minWidth: 200 },
  1093. {
  1094. title: 'Attributes',
  1095. field: 'attributes_summary',
  1096. minWidth: 240,
  1097. formatter: function (cell) {
  1098. const v = cell.getValue();
  1099. return v ? '<span class="attr-summary">' + _escapeHtml(v) + '</span>'
  1100. : '<span class="attr-empty">&mdash;</span>';
  1101. },
  1102. },
  1103. { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 },
  1104. { title: 'Created', field: 'created_at', minWidth: 160 },
  1105. ],
  1106. });
  1107. },
  1108. reloadTable() {
  1109. if (!this.table) {
  1110. this.initTable();
  1111. return;
  1112. }
  1113. this.table.setData('/job-types/data');
  1114. },
  1115. };
  1116. };
  1117. window.deleteJobType = function (id) {
  1118. if (!confirm('Delete this job type? This cannot be undone.')) {
  1119. return;
  1120. }
  1121. _postDelete('/job-types/' + id + '/delete');
  1122. };
  1123. window.jobTypeForm = function (initialAttributes, customerTypes) {
  1124. return {
  1125. attributes: Array.isArray(initialAttributes) ? initialAttributes : [],
  1126. customerTypes: Array.isArray(customerTypes) ? customerTypes : [],
  1127. dragIndex: null,
  1128. dragOverIndex: null,
  1129. addAttribute() {
  1130. this.attributes.push({
  1131. name: '', type: 'text', alias: '', order: this.attributes.length + 1,
  1132. api_url: '', api_match_field: '', api_auto_fill: '', api_format: 'json', api_return_type: 'text',
  1133. customer_type_id: 0,
  1134. });
  1135. },
  1136. importCustomerTypeAttributes(index) {
  1137. var row = this.attributes[index];
  1138. if (!row) return;
  1139. var ctId = Number(row.customer_type_id || 0);
  1140. if (!ctId) return;
  1141. var ct = this.customerTypes.find(function (c) { return Number(c.id) === ctId; });
  1142. if (!ct || !Array.isArray(ct.attributes) || ct.attributes.length === 0) {
  1143. row.customer_type_id = 0;
  1144. return;
  1145. }
  1146. var imported = ct.attributes.slice().sort(function (a, b) {
  1147. return (a.order || 0) - (b.order || 0);
  1148. }).map(function (a) {
  1149. var importedType = (a.type === 'api_lookup') ? 'text' : (a.type || 'text');
  1150. return {
  1151. name: a.name || '',
  1152. type: importedType,
  1153. alias: a.alias || '',
  1154. order: 0,
  1155. api_url: '',
  1156. api_match_field: '',
  1157. api_auto_fill: '',
  1158. api_format: a.api_format || 'json',
  1159. api_return_type: a.api_return_type || 'text',
  1160. customer_type_id: 0,
  1161. };
  1162. });
  1163. if (row.type === 'customer_lookup') {
  1164. // Keep the lookup row; stamp api_match_field from the customer type, then insert attrs after it.
  1165. row.api_match_field = ct.api_match_field || '';
  1166. this.attributes.splice.apply(this.attributes, [index + 1, 0].concat(imported));
  1167. } else {
  1168. // Legacy customer placeholder — replace with expanded attrs.
  1169. this.attributes.splice.apply(this.attributes, [index, 1].concat(imported));
  1170. }
  1171. this.renumberOrder();
  1172. },
  1173. removeAttribute(index) {
  1174. this.attributes.splice(index, 1);
  1175. this.renumberOrder();
  1176. },
  1177. renumberOrder() {
  1178. this.attributes.forEach(function (attr, i) { attr.order = i + 1; });
  1179. },
  1180. dragStart(event, index) {
  1181. this.dragIndex = index;
  1182. event.dataTransfer.effectAllowed = 'move';
  1183. },
  1184. dragOver(event, index) {
  1185. this.dragOverIndex = index;
  1186. },
  1187. drop(event, index) {
  1188. if (this.dragIndex !== null && this.dragIndex !== index) {
  1189. var moved = this.attributes.splice(this.dragIndex, 1)[0];
  1190. this.attributes.splice(index, 0, moved);
  1191. this.renumberOrder();
  1192. }
  1193. this.dragIndex = null;
  1194. this.dragOverIndex = null;
  1195. },
  1196. dragEnd() {
  1197. this.dragIndex = null;
  1198. this.dragOverIndex = null;
  1199. },
  1200. confirmDelete(event) {
  1201. if (confirm('Delete this job type? This cannot be undone.')) {
  1202. event.target.submit();
  1203. }
  1204. },
  1205. };
  1206. };
  1207. // ── Job ───────────────────────────────────────────────────────────────────────
  1208. window.jobTable = function () {
  1209. return {
  1210. table: null,
  1211. isLoading: false,
  1212. errorMessage: '',
  1213. configMessage: '',
  1214. persistenceKey: 'jobs-directory',
  1215. init() {
  1216. this.loadTable();
  1217. },
  1218. async loadTable() {
  1219. const el = document.getElementById('job-table');
  1220. if (!el || typeof Tabulator === 'undefined' || this.isLoading) {
  1221. return;
  1222. }
  1223. this.isLoading = true;
  1224. this.errorMessage = '';
  1225. try {
  1226. const response = await fetch('/jobs/data', {
  1227. headers: { Accept: 'application/json' },
  1228. });
  1229. if (!response.ok) {
  1230. throw new Error('Unable to load jobs.');
  1231. }
  1232. const rows = await response.json();
  1233. const jobRows = Array.isArray(rows) ? rows : [];
  1234. const attributes = this.attributeColumnsForRows(jobRows);
  1235. const tableRows = this.formatRows(jobRows, attributes);
  1236. const columns = this.columnsForAttributes(attributes);
  1237. if (this.table) {
  1238. this.table.destroy();
  1239. this.table = null;
  1240. }
  1241. this.table = new Tabulator(el, {
  1242. data: tableRows,
  1243. layout: 'fitData',
  1244. pagination: true,
  1245. paginationMode: 'local',
  1246. paginationSize: 10,
  1247. paginationSizeSelector: PAGE_SIZES,
  1248. movableColumns: true,
  1249. placeholder: 'No jobs found.',
  1250. initialSort: [{ column: 'job_type_name', dir: 'asc' }],
  1251. columns: columns,
  1252. ..._tabulatorBaseOptions(this.persistenceKey),
  1253. });
  1254. } catch (error) {
  1255. this.errorMessage = error.message || 'Unable to load jobs.';
  1256. } finally {
  1257. this.isLoading = false;
  1258. }
  1259. },
  1260. attributeColumnsForRows(rows) {
  1261. return _discoverAttributeColumns(rows, 'job_type_attributes', this.normalizeAttributes);
  1262. },
  1263. normalizeAttributes(attributes) {
  1264. return attributes
  1265. .filter((attr) => attr && attr.name)
  1266. .slice()
  1267. .sort((a, b) => (a.order || 0) - (b.order || 0));
  1268. },
  1269. formatRows(rows, attributes) {
  1270. return rows.map((row) => {
  1271. const attributeValues = row.attribute_values || {};
  1272. const tableRow = {
  1273. id: row.id,
  1274. edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit',
  1275. campaign_id: row.campaign_id || '',
  1276. campaign_type_name: row.campaign_type_name || '',
  1277. job_type_id: row.job_type_id || '',
  1278. job_type_name: row.job_type_name || '',
  1279. created_at: row.created_at || '',
  1280. updated_at: row.updated_at || '',
  1281. };
  1282. attributes.forEach((attr) => {
  1283. tableRow[attr.field] = this.formatAttributeValue(attributeValues[attr.name] ?? '');
  1284. });
  1285. return tableRow;
  1286. });
  1287. },
  1288. formatAttributeValue(value) {
  1289. if (value === null || value === undefined) {
  1290. return '';
  1291. }
  1292. if (Array.isArray(value) || typeof value === 'object') {
  1293. return JSON.stringify(value);
  1294. }
  1295. return String(value);
  1296. },
  1297. columnsForAttributes(attributes) {
  1298. const columns = [
  1299. {
  1300. title: 'Actions',
  1301. field: 'edit_url',
  1302. width: 160,
  1303. hozAlign: 'center',
  1304. headerSort: false,
  1305. formatter: function (cell) {
  1306. const url = cell.getValue();
  1307. const id = cell.getRow().getData().id;
  1308. return '<a href="' + _escapeHtml(url) + '" class="button button-secondary button-sm">Edit</a> ' +
  1309. '<button onclick="window.deleteJob(' + id + ')" class="button button-danger button-sm">Delete</button>';
  1310. },
  1311. },
  1312. { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' },
  1313. { title: 'Campaign', field: 'campaign_type_name', minWidth: 160, headerFilter: 'input' },
  1314. { title: 'Job Type', field: 'job_type_name', minWidth: 160, headerFilter: 'input' },
  1315. ];
  1316. attributes.forEach((attr) => {
  1317. columns.push({
  1318. title: attr.name,
  1319. field: attr.field,
  1320. minWidth: 150,
  1321. headerFilter: 'input',
  1322. formatter: function (cell) {
  1323. const value = cell.getValue();
  1324. return value ? _escapeHtml(value) : '<span class="attr-empty">&mdash;</span>';
  1325. },
  1326. });
  1327. });
  1328. columns.push(
  1329. { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' },
  1330. { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' }
  1331. );
  1332. return columns;
  1333. },
  1334. reloadTable() {
  1335. this.loadTable();
  1336. },
  1337. async exportCsv() {
  1338. try {
  1339. await _exportTabulatorCsv('/jobs/export', this.table, 'jobs-export.csv');
  1340. } catch (error) {
  1341. this.errorMessage = error.message || 'Unable to export CSV.';
  1342. }
  1343. },
  1344. showConfiguration() {
  1345. if (!this.table) {
  1346. return;
  1347. }
  1348. const shouldReset = window.confirm('Reset this table configuration? This clears saved columns, filters, sorting, and page size.');
  1349. if (!shouldReset) {
  1350. return;
  1351. }
  1352. _clearTabulatorPersistence(this.persistenceKey);
  1353. this.configMessage = 'Table configuration reset.';
  1354. this.loadTable();
  1355. window.setTimeout(() => {
  1356. this.configMessage = '';
  1357. }, 3000);
  1358. },
  1359. };
  1360. };
  1361. window.deleteJob = function (id) {
  1362. if (!confirm('Delete this job? This cannot be undone.')) {
  1363. return;
  1364. }
  1365. _postDelete('/jobs/' + id + '/delete');
  1366. };
  1367. window.jobForm = function (jobTypes, initialTypeId, initialValues) {
  1368. return {
  1369. jobTypes: jobTypes,
  1370. selectedTypeId: String(initialTypeId || ''),
  1371. attributeValues: Object.assign({}, initialValues || {}),
  1372. apiLookupState: {},
  1373. apiLookupError: {},
  1374. apiLookupOptions: {},
  1375. apiLookupOpen: {},
  1376. customerLookupState: {},
  1377. customerLookupOptions: {},
  1378. customerLookupOpen: {},
  1379. get currentType() {
  1380. var id = this.selectedTypeId;
  1381. if (!id) return null;
  1382. return this.jobTypes.find(function (t) { return String(t.id) === String(id); }) || null;
  1383. },
  1384. get currentAttributes() {
  1385. if (!this.currentType) return [];
  1386. return this.currentType.attributes.slice().sort(function (a, b) {
  1387. return (a.order || 0) - (b.order || 0);
  1388. });
  1389. },
  1390. init() {
  1391. var self = this;
  1392. this.currentAttributes.forEach(function (attr) {
  1393. if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
  1394. if (attr.type === 'customer_lookup') { self.fetchCustomers(attr); }
  1395. });
  1396. },
  1397. onTypeChange() {
  1398. this.attributeValues = {};
  1399. this.apiLookupOptions = {};
  1400. this.apiLookupState = {};
  1401. this.apiLookupOpen = {};
  1402. this.customerLookupOptions = {};
  1403. this.customerLookupState = {};
  1404. this.customerLookupOpen = {};
  1405. var self = this;
  1406. this.$nextTick(function () {
  1407. self.currentAttributes.forEach(function (attr) {
  1408. if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
  1409. if (attr.type === 'customer_lookup') { self.fetchCustomers(attr); }
  1410. });
  1411. });
  1412. },
  1413. inputType(attrType) {
  1414. return ['number', 'date'].includes(attrType) ? attrType : 'text';
  1415. },
  1416. getApiOptions(name) {
  1417. return this.apiLookupOptions[name] || { fields: [], records: [] };
  1418. },
  1419. openApiLookup(name) {
  1420. var o = {}; o[name] = true;
  1421. this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o);
  1422. },
  1423. closeApiLookup(name) {
  1424. var o = {}; o[name] = false;
  1425. this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o);
  1426. },
  1427. getFilteredRecords(name, search) {
  1428. var records = this.getApiOptions(name).records;
  1429. if (!search) return records;
  1430. var term = String(search).toLowerCase();
  1431. return records.filter(function (rec) {
  1432. return rec._display.some(function (v) {
  1433. return String(v).toLowerCase().indexOf(term) !== -1;
  1434. });
  1435. });
  1436. },
  1437. selectApiOption(attr, rec) {
  1438. var newValues = Object.assign({}, this.attributeValues);
  1439. newValues[attr.name] = rec._primary;
  1440. var autoFill = (attr.api_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean);
  1441. var attrs = this.currentAttributes;
  1442. autoFill.forEach(function (alias) {
  1443. var target = null;
  1444. for (var i = 0; i < attrs.length; i++) {
  1445. if (attrs[i].alias === alias) { target = attrs[i]; break; }
  1446. }
  1447. if (!target) return;
  1448. var rowVal = rec._row[alias];
  1449. if (rowVal !== undefined && rowVal !== null) {
  1450. newValues[target.name] = String(rowVal);
  1451. }
  1452. });
  1453. this.attributeValues = newValues;
  1454. this.closeApiLookup(attr.name);
  1455. },
  1456. fetchApiValue(attr) {
  1457. var self = this;
  1458. if (!attr.api_url) return;
  1459. var resolvedUrl = attr.api_url.replace(/\{alias\}/g, encodeURIComponent(attr.alias || ''));
  1460. // Reassign whole objects so Alpine sees new references and re-renders nested x-for
  1461. var s = {}; s[attr.name] = 'loading';
  1462. var e = {}; e[attr.name] = '';
  1463. var o = {}; o[attr.name] = { fields: [], records: [] };
  1464. self.apiLookupState = Object.assign({}, self.apiLookupState, s);
  1465. self.apiLookupError = Object.assign({}, self.apiLookupError, e);
  1466. self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, o);
  1467. var matchFields = (attr.api_match_field || '')
  1468. .split(';')
  1469. .map(function (s) { return s.trim(); })
  1470. .filter(Boolean);
  1471. fetch('/api/proxy?url=' + encodeURIComponent(resolvedUrl))
  1472. .then(function (res) { return res.json(); })
  1473. .then(function (envelope) {
  1474. if (envelope.error) {
  1475. var se = {}; se[attr.name] = envelope.error;
  1476. var ss = {}; ss[attr.name] = 'error';
  1477. self.apiLookupError = Object.assign({}, self.apiLookupError, se);
  1478. self.apiLookupState = Object.assign({}, self.apiLookupState, ss);
  1479. return;
  1480. }
  1481. var body = envelope.body || '';
  1482. var result = { fields: matchFields.slice(), records: [] };
  1483. var seenRows = [];
  1484. function addRow(rawRow) {
  1485. if (seenRows.indexOf(rawRow) !== -1) return;
  1486. seenRows.push(rawRow);
  1487. var display = result.fields.map(function (f) {
  1488. var v = rawRow[f]; return (v !== undefined && v !== null) ? String(v) : '';
  1489. });
  1490. result.records.push({ _primary: display[0] || '', _display: display, _row: rawRow });
  1491. }
  1492. if (attr.api_format === 'xml') {
  1493. try {
  1494. var doc = new DOMParser().parseFromString(body, 'text/xml');
  1495. if (result.fields.length === 0) { result.fields = [doc.documentElement.tagName]; }
  1496. // Collect sibling-based rows keyed by the first match field element
  1497. var firstField = result.fields[0];
  1498. var els = doc.getElementsByTagName(firstField);
  1499. for (var xi = 0; xi < els.length; xi++) {
  1500. var row = {};
  1501. var par = els[xi].parentNode;
  1502. if (par) {
  1503. for (var xc = 0; xc < par.childNodes.length; xc++) {
  1504. var cn = par.childNodes[xc];
  1505. if (cn.nodeType === 1) { row[cn.tagName] = cn.textContent.trim(); }
  1506. }
  1507. }
  1508. addRow(row);
  1509. }
  1510. } catch (e) { /* leave records empty */ }
  1511. } else {
  1512. try {
  1513. var parsed = JSON.parse(body);
  1514. if (result.fields.length > 0) {
  1515. // Collect unique parent rows that own the first match field
  1516. _deepFindRows(parsed, result.fields[0]).forEach(function (hit) { addRow(hit.row); });
  1517. } else if (Array.isArray(parsed)) {
  1518. result.fields = ['Value'];
  1519. parsed.forEach(function (item) {
  1520. if (typeof item !== 'object') { addRow({ Value: String(item) }); }
  1521. });
  1522. } else if (typeof parsed === 'string' || typeof parsed === 'number' || typeof parsed === 'boolean') {
  1523. result.fields = ['Value'];
  1524. addRow({ Value: String(parsed) });
  1525. }
  1526. } catch (e) { /* leave records empty */ }
  1527. }
  1528. var oo = {}; oo[attr.name] = result;
  1529. var os = {}; os[attr.name] = 'idle';
  1530. self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, oo);
  1531. self.apiLookupState = Object.assign({}, self.apiLookupState, os);
  1532. })
  1533. .catch(function (err) {
  1534. console.error('[api-lookup] fetch failed:', err);
  1535. var ce = {}; ce[attr.name] = 'Network error — see browser console.';
  1536. var cs = {}; cs[attr.name] = 'error';
  1537. self.apiLookupError = Object.assign({}, self.apiLookupError, ce);
  1538. self.apiLookupState = Object.assign({}, self.apiLookupState, cs);
  1539. });
  1540. },
  1541. getCustomerOptions(name) {
  1542. return this.customerLookupOptions[name] || { fields: [], records: [] };
  1543. },
  1544. openCustomerLookup(name) {
  1545. var o = {}; o[name] = true;
  1546. this.customerLookupOpen = Object.assign({}, this.customerLookupOpen, o);
  1547. },
  1548. closeCustomerLookup(name) {
  1549. var o = {}; o[name] = false;
  1550. this.customerLookupOpen = Object.assign({}, this.customerLookupOpen, o);
  1551. },
  1552. getFilteredCustomers(name, search) {
  1553. var records = this.getCustomerOptions(name).records;
  1554. if (!search) return records;
  1555. var term = String(search).toLowerCase();
  1556. return records.filter(function (rec) {
  1557. return rec._display.some(function (v) {
  1558. return String(v).toLowerCase().indexOf(term) !== -1;
  1559. });
  1560. });
  1561. },
  1562. selectCustomer(attr, customer) {
  1563. var newValues = Object.assign({}, this.attributeValues);
  1564. newValues[attr.name] = customer._primary;
  1565. var av = customer._raw.attribute_values || {};
  1566. Object.keys(av).forEach(function (key) {
  1567. newValues[key] = String(av[key] !== null && av[key] !== undefined ? av[key] : '');
  1568. });
  1569. this.attributeValues = newValues;
  1570. this.closeCustomerLookup(attr.name);
  1571. },
  1572. fetchCustomers(attr) {
  1573. var self = this;
  1574. var ctId = Number(attr.customer_type_id || 0);
  1575. var matchField = attr.api_match_field || '';
  1576. if (!ctId) return;
  1577. var s = {}; s[attr.name] = 'loading';
  1578. var o = {}; o[attr.name] = { fields: [], records: [] };
  1579. self.customerLookupState = Object.assign({}, self.customerLookupState, s);
  1580. self.customerLookupOptions = Object.assign({}, self.customerLookupOptions, o);
  1581. fetch('/api/customers?customer_type_id=' + ctId, { headers: { Accept: 'application/json' } })
  1582. .then(function (res) { return res.json(); })
  1583. .then(function (rows) {
  1584. var ss = {}; ss[attr.name] = 'idle';
  1585. if (!Array.isArray(rows) || rows.length === 0) {
  1586. self.customerLookupState = Object.assign({}, self.customerLookupState, ss);
  1587. return;
  1588. }
  1589. var attrKeys = Object.keys(rows[0].attribute_values || {});
  1590. var records = rows.map(function (c) {
  1591. var av = c.attribute_values || {};
  1592. var display = attrKeys.map(function (k) { return av[k] !== undefined && av[k] !== null ? String(av[k]) : ''; });
  1593. var primary = matchField && av[matchField] !== undefined ? String(av[matchField]) : String(c.id);
  1594. return { _primary: primary, _display: display, _raw: c };
  1595. });
  1596. var oo = {}; oo[attr.name] = { fields: attrKeys, records: records };
  1597. self.customerLookupOptions = Object.assign({}, self.customerLookupOptions, oo);
  1598. self.customerLookupState = Object.assign({}, self.customerLookupState, ss);
  1599. })
  1600. .catch(function (err) {
  1601. console.error('[customer-lookup] fetch failed:', err);
  1602. var cs = {}; cs[attr.name] = 'error';
  1603. self.customerLookupState = Object.assign({}, self.customerLookupState, cs);
  1604. });
  1605. },
  1606. confirmDelete(event) {
  1607. if (confirm('Delete this job? This cannot be undone.')) {
  1608. event.target.submit();
  1609. }
  1610. },
  1611. };
  1612. };
  1613. // ── Customer Type ─────────────────────────────────────────────────────────────
  1614. window.customerTypeTable = function () {
  1615. return {
  1616. table: null,
  1617. init() {
  1618. this.initTable();
  1619. },
  1620. initTable() {
  1621. const el = document.getElementById('customer-type-table');
  1622. if (!el || typeof Tabulator === 'undefined') {
  1623. return;
  1624. }
  1625. this.table = new Tabulator(el, {
  1626. ajaxURL: '/customer-types/data',
  1627. layout: 'fitColumns',
  1628. responsiveLayout: 'collapse',
  1629. pagination: true,
  1630. paginationMode: 'local',
  1631. paginationSize: 10,
  1632. paginationSizeSelector: PAGE_SIZES,
  1633. movableColumns: true,
  1634. placeholder: 'No customer types found.',
  1635. initialSort: [{ column: 'name', dir: 'asc' }],
  1636. columns: [
  1637. {
  1638. title: 'Actions',
  1639. field: 'id',
  1640. width: 160,
  1641. hozAlign: 'center',
  1642. headerSort: false,
  1643. formatter: function (cell) {
  1644. const id = cell.getValue();
  1645. return '<a href="/customer-types/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' +
  1646. '<button onclick="window.deleteCustomerType(' + id + ')" class="button button-danger button-sm">Delete</button>';
  1647. },
  1648. },
  1649. { title: 'Name', field: 'name', minWidth: 200 },
  1650. {
  1651. title: 'Attributes',
  1652. field: 'attributes_summary',
  1653. minWidth: 240,
  1654. formatter: function (cell) {
  1655. const v = cell.getValue();
  1656. return v ? '<span class="attr-summary">' + _escapeHtml(v) + '</span>'
  1657. : '<span class="attr-empty">&mdash;</span>';
  1658. },
  1659. },
  1660. { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 },
  1661. { title: 'Created', field: 'created_at', minWidth: 160 },
  1662. ],
  1663. });
  1664. },
  1665. reloadTable() {
  1666. if (!this.table) {
  1667. this.initTable();
  1668. return;
  1669. }
  1670. this.table.setData('/customer-types/data');
  1671. },
  1672. };
  1673. };
  1674. window.deleteCustomerType = function (id) {
  1675. if (!confirm('Delete this customer type? This cannot be undone.')) {
  1676. return;
  1677. }
  1678. _postDelete('/customer-types/' + id + '/delete');
  1679. };
  1680. window.customerTypeForm = function (initialAttributes) {
  1681. return {
  1682. attributes: Array.isArray(initialAttributes) ? initialAttributes : [],
  1683. dragIndex: null,
  1684. dragOverIndex: null,
  1685. addAttribute() {
  1686. 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' });
  1687. },
  1688. removeAttribute(index) {
  1689. this.attributes.splice(index, 1);
  1690. this.renumberOrder();
  1691. },
  1692. renumberOrder() {
  1693. this.attributes.forEach(function (attr, i) { attr.order = i + 1; });
  1694. },
  1695. dragStart(event, index) {
  1696. this.dragIndex = index;
  1697. event.dataTransfer.effectAllowed = 'move';
  1698. },
  1699. dragOver(event, index) {
  1700. this.dragOverIndex = index;
  1701. },
  1702. drop(event, index) {
  1703. if (this.dragIndex !== null && this.dragIndex !== index) {
  1704. var moved = this.attributes.splice(this.dragIndex, 1)[0];
  1705. this.attributes.splice(index, 0, moved);
  1706. this.renumberOrder();
  1707. }
  1708. this.dragIndex = null;
  1709. this.dragOverIndex = null;
  1710. },
  1711. dragEnd() {
  1712. this.dragIndex = null;
  1713. this.dragOverIndex = null;
  1714. },
  1715. confirmDelete(event) {
  1716. if (confirm('Delete this customer type? This cannot be undone.')) {
  1717. event.target.submit();
  1718. }
  1719. },
  1720. };
  1721. };
  1722. // ── Customer ──────────────────────────────────────────────────────────────────
  1723. window.customerTable = function () {
  1724. return {
  1725. table: null,
  1726. isLoading: false,
  1727. errorMessage: '',
  1728. init() {
  1729. this.loadTable();
  1730. },
  1731. async loadTable() {
  1732. const el = document.getElementById('customer-table');
  1733. if (!el || typeof Tabulator === 'undefined' || this.isLoading) {
  1734. return;
  1735. }
  1736. this.isLoading = true;
  1737. this.errorMessage = '';
  1738. try {
  1739. const response = await fetch('/customers/data', {
  1740. headers: { Accept: 'application/json' },
  1741. });
  1742. if (!response.ok) {
  1743. throw new Error('Unable to load customers.');
  1744. }
  1745. const rows = await response.json();
  1746. const customerRows = Array.isArray(rows) ? rows : [];
  1747. const attributes = this.attributeColumnsForRows(customerRows);
  1748. const tableRows = this.formatRows(customerRows, attributes);
  1749. const columns = this.columnsForAttributes(attributes);
  1750. if (this.table) {
  1751. this.table.destroy();
  1752. this.table = null;
  1753. }
  1754. this.table = new Tabulator(el, {
  1755. data: tableRows,
  1756. layout: 'fitData',
  1757. pagination: true,
  1758. paginationMode: 'local',
  1759. paginationSize: 10,
  1760. paginationSizeSelector: PAGE_SIZES,
  1761. movableColumns: true,
  1762. placeholder: 'No customers found.',
  1763. initialSort: [{ column: 'customer_type_name', dir: 'asc' }],
  1764. columns: columns,
  1765. });
  1766. } catch (error) {
  1767. this.errorMessage = error.message || 'Unable to load customers.';
  1768. } finally {
  1769. this.isLoading = false;
  1770. }
  1771. },
  1772. attributeColumnsForRows(rows) {
  1773. const attributes = [];
  1774. rows.forEach((row) => {
  1775. this.normalizeAttributes(row.customer_type_attributes || []).forEach((attr) => {
  1776. if (!attributes.some((existing) => existing.name === attr.name)) {
  1777. attributes.push(attr);
  1778. }
  1779. });
  1780. Object.keys(row.attribute_values || {}).forEach((name) => {
  1781. if (!attributes.some((existing) => existing.name === name)) {
  1782. attributes.push({ name: name, type: 'text', order: attributes.length + 1 });
  1783. }
  1784. });
  1785. });
  1786. return attributes;
  1787. },
  1788. normalizeAttributes(attributes) {
  1789. return attributes
  1790. .filter((attr) => attr && attr.name)
  1791. .slice()
  1792. .sort((a, b) => (a.order || 0) - (b.order || 0));
  1793. },
  1794. formatRows(rows, attributes) {
  1795. return rows.map((row) => {
  1796. const attributeValues = row.attribute_values || {};
  1797. const tableRow = {
  1798. id: row.id,
  1799. edit_url: '/customers/' + encodeURIComponent(row.id) + '/edit',
  1800. customer_type_id: row.customer_type_id || '',
  1801. customer_type_name: row.customer_type_name || '',
  1802. created_at: row.created_at || '',
  1803. };
  1804. attributes.forEach((attr, index) => {
  1805. tableRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? '');
  1806. });
  1807. return tableRow;
  1808. });
  1809. },
  1810. formatAttributeValue(value) {
  1811. if (value === null || value === undefined) {
  1812. return '';
  1813. }
  1814. if (Array.isArray(value) || typeof value === 'object') {
  1815. return JSON.stringify(value);
  1816. }
  1817. return String(value);
  1818. },
  1819. columnsForAttributes(attributes) {
  1820. const columns = [
  1821. {
  1822. title: 'Actions',
  1823. field: 'edit_url',
  1824. width: 160,
  1825. hozAlign: 'center',
  1826. headerSort: false,
  1827. formatter: function (cell) {
  1828. const url = cell.getValue();
  1829. const id = cell.getRow().getData().id;
  1830. return '<a href="' + _escapeHtml(url) + '" class="button button-secondary button-sm">Edit</a> ' +
  1831. '<button onclick="window.deleteCustomer(' + id + ')" class="button button-danger button-sm">Delete</button>';
  1832. },
  1833. },
  1834. { title: 'Customer ID', field: 'id', width: 110, hozAlign: 'center', headerFilter: 'input' },
  1835. { title: 'Customer Type', field: 'customer_type_name', minWidth: 180, headerFilter: 'input' },
  1836. ];
  1837. attributes.forEach((attr, index) => {
  1838. columns.push({
  1839. title: attr.name,
  1840. field: 'attr_' + index,
  1841. minWidth: 150,
  1842. headerFilter: 'input',
  1843. formatter: function (cell) {
  1844. const value = cell.getValue();
  1845. return value ? _escapeHtml(value) : '<span class="attr-empty">&mdash;</span>';
  1846. },
  1847. });
  1848. });
  1849. columns.push(
  1850. { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' }
  1851. );
  1852. return columns;
  1853. },
  1854. reloadTable() {
  1855. this.loadTable();
  1856. },
  1857. };
  1858. };
  1859. window.deleteCustomer = function (id) {
  1860. if (!confirm('Delete this customer? This cannot be undone.')) {
  1861. return;
  1862. }
  1863. _postDelete('/customers/' + id + '/delete');
  1864. };
  1865. window.customerForm = function (customerTypes, initialTypeId, initialValues) {
  1866. return {
  1867. customerTypes: customerTypes,
  1868. selectedTypeId: String(initialTypeId || ''),
  1869. attributeValues: Object.assign({}, initialValues || {}),
  1870. apiLookupState: {},
  1871. apiLookupError: {},
  1872. apiLookupOptions: {},
  1873. apiLookupOpen: {},
  1874. // CSV import
  1875. csvStep: 'idle',
  1876. csvFileSelected: false,
  1877. isCsvUploading: false,
  1878. isCsvPreviewing: false,
  1879. isCsvApproving: false,
  1880. csvError: '',
  1881. csvHeaders: [],
  1882. csvMapping: {},
  1883. csvTempName: '',
  1884. csvPreviewRows: [],
  1885. csvPreviewStats: { total: 0, ok: 0, duplicate: 0, empty: 0 },
  1886. csvDoneMessage: '',
  1887. csvApproveErrors: [],
  1888. get currentType() {
  1889. var id = this.selectedTypeId;
  1890. if (!id) return null;
  1891. return this.customerTypes.find(function (t) { return String(t.id) === String(id); }) || null;
  1892. },
  1893. get currentAttributes() {
  1894. if (!this.currentType) return [];
  1895. return this.currentType.attributes.slice().sort(function (a, b) {
  1896. return (a.order || 0) - (b.order || 0);
  1897. });
  1898. },
  1899. init() {
  1900. var self = this;
  1901. this.currentAttributes.forEach(function (attr) {
  1902. if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
  1903. });
  1904. },
  1905. onTypeChange() {
  1906. this.attributeValues = {};
  1907. this.apiLookupOptions = {};
  1908. this.apiLookupState = {};
  1909. this.apiLookupOpen = {};
  1910. this.resetCsvImport();
  1911. var self = this;
  1912. this.$nextTick(function () {
  1913. self.currentAttributes.forEach(function (attr) {
  1914. if (attr.type === 'api_lookup') { self.fetchApiValue(attr); }
  1915. });
  1916. });
  1917. },
  1918. inputType(attrType) {
  1919. return ['number', 'date'].includes(attrType) ? attrType : 'text';
  1920. },
  1921. getApiOptions(name) {
  1922. return this.apiLookupOptions[name] || { fields: [], records: [] };
  1923. },
  1924. openApiLookup(name) {
  1925. var o = {}; o[name] = true;
  1926. this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o);
  1927. },
  1928. closeApiLookup(name) {
  1929. var o = {}; o[name] = false;
  1930. this.apiLookupOpen = Object.assign({}, this.apiLookupOpen, o);
  1931. },
  1932. getFilteredRecords(name, search) {
  1933. var records = this.getApiOptions(name).records;
  1934. if (!search) return records;
  1935. var term = String(search).toLowerCase();
  1936. return records.filter(function (rec) {
  1937. return rec._display.some(function (v) {
  1938. return String(v).toLowerCase().indexOf(term) !== -1;
  1939. });
  1940. });
  1941. },
  1942. selectApiOption(attr, rec) {
  1943. var newValues = Object.assign({}, this.attributeValues);
  1944. newValues[attr.name] = rec._primary;
  1945. var autoFill = (attr.api_auto_fill || '').split(';').map(function (s) { return s.trim(); }).filter(Boolean);
  1946. var attrs = this.currentAttributes;
  1947. autoFill.forEach(function (alias) {
  1948. var target = null;
  1949. for (var i = 0; i < attrs.length; i++) {
  1950. if (attrs[i].alias === alias) { target = attrs[i]; break; }
  1951. }
  1952. if (!target) return;
  1953. var rowVal = rec._row[alias];
  1954. if (rowVal !== undefined && rowVal !== null) {
  1955. newValues[target.name] = String(rowVal);
  1956. }
  1957. });
  1958. this.attributeValues = newValues;
  1959. this.closeApiLookup(attr.name);
  1960. },
  1961. fetchApiValue(attr) {
  1962. var self = this;
  1963. if (!attr.api_url) return;
  1964. var resolvedUrl = attr.api_url.replace(/\{alias\}/g, encodeURIComponent(attr.alias || ''));
  1965. var s = {}; s[attr.name] = 'loading';
  1966. var e = {}; e[attr.name] = '';
  1967. var o = {}; o[attr.name] = { fields: [], records: [] };
  1968. self.apiLookupState = Object.assign({}, self.apiLookupState, s);
  1969. self.apiLookupError = Object.assign({}, self.apiLookupError, e);
  1970. self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, o);
  1971. var matchFields = (attr.api_match_field || '')
  1972. .split(';')
  1973. .map(function (s) { return s.trim(); })
  1974. .filter(Boolean);
  1975. fetch('/api/proxy?url=' + encodeURIComponent(resolvedUrl))
  1976. .then(function (res) { return res.json(); })
  1977. .then(function (envelope) {
  1978. if (envelope.error) {
  1979. var se = {}; se[attr.name] = envelope.error;
  1980. var ss = {}; ss[attr.name] = 'error';
  1981. self.apiLookupError = Object.assign({}, self.apiLookupError, se);
  1982. self.apiLookupState = Object.assign({}, self.apiLookupState, ss);
  1983. return;
  1984. }
  1985. var body = envelope.body || '';
  1986. var result = { fields: matchFields.slice(), records: [] };
  1987. var seenRows = [];
  1988. function addRow(rawRow) {
  1989. if (seenRows.indexOf(rawRow) !== -1) return;
  1990. seenRows.push(rawRow);
  1991. var display = result.fields.map(function (f) {
  1992. var v = rawRow[f]; return (v !== undefined && v !== null) ? String(v) : '';
  1993. });
  1994. result.records.push({ _primary: display[0] || '', _display: display, _row: rawRow });
  1995. }
  1996. if (attr.api_format === 'xml') {
  1997. try {
  1998. var doc = new DOMParser().parseFromString(body, 'text/xml');
  1999. if (result.fields.length === 0) { result.fields = [doc.documentElement.tagName]; }
  2000. var firstField = result.fields[0];
  2001. var els = doc.getElementsByTagName(firstField);
  2002. for (var xi = 0; xi < els.length; xi++) {
  2003. var row = {};
  2004. var par = els[xi].parentNode;
  2005. if (par) {
  2006. for (var xc = 0; xc < par.childNodes.length; xc++) {
  2007. var cn = par.childNodes[xc];
  2008. if (cn.nodeType === 1) { row[cn.tagName] = cn.textContent.trim(); }
  2009. }
  2010. }
  2011. addRow(row);
  2012. }
  2013. } catch (e) { /* leave records empty */ }
  2014. } else {
  2015. try {
  2016. var parsed = JSON.parse(body);
  2017. if (result.fields.length > 0) {
  2018. _deepFindRows(parsed, result.fields[0]).forEach(function (hit) { addRow(hit.row); });
  2019. } else if (Array.isArray(parsed)) {
  2020. result.fields = ['Value'];
  2021. parsed.forEach(function (item) {
  2022. if (typeof item !== 'object') { addRow({ Value: String(item) }); }
  2023. });
  2024. } else if (typeof parsed === 'string' || typeof parsed === 'number' || typeof parsed === 'boolean') {
  2025. result.fields = ['Value'];
  2026. addRow({ Value: String(parsed) });
  2027. }
  2028. } catch (e) { /* leave records empty */ }
  2029. }
  2030. var oo = {}; oo[attr.name] = result;
  2031. var os = {}; os[attr.name] = 'idle';
  2032. self.apiLookupOptions = Object.assign({}, self.apiLookupOptions, oo);
  2033. self.apiLookupState = Object.assign({}, self.apiLookupState, os);
  2034. })
  2035. .catch(function (err) {
  2036. console.error('[api-lookup] fetch failed:', err);
  2037. var ce = {}; ce[attr.name] = 'Network error — see browser console.';
  2038. var cs = {}; cs[attr.name] = 'error';
  2039. self.apiLookupError = Object.assign({}, self.apiLookupError, ce);
  2040. self.apiLookupState = Object.assign({}, self.apiLookupState, cs);
  2041. });
  2042. },
  2043. confirmDelete(event) {
  2044. if (confirm('Delete this customer? This cannot be undone.')) {
  2045. event.target.submit();
  2046. }
  2047. },
  2048. // ── CSV import ────────────────────────────────────────────────────────
  2049. onCsvFileSelect(event) {
  2050. this.csvFileSelected = !!(event.target.files && event.target.files.length > 0);
  2051. this.csvError = '';
  2052. },
  2053. async uploadCsv() {
  2054. var fileInput = this.$refs.csvFileInput;
  2055. if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
  2056. this.csvError = 'No file selected.';
  2057. return;
  2058. }
  2059. this.isCsvUploading = true;
  2060. this.csvError = '';
  2061. try {
  2062. var form = new FormData();
  2063. form.set('_token', window.__csrf || '');
  2064. form.set('csv_file', fileInput.files[0]);
  2065. var response = await fetch('/customers/import/upload', {
  2066. method: 'POST',
  2067. headers: { Accept: 'application/json' },
  2068. body: form,
  2069. });
  2070. var data = await response.json().catch(function () { return {}; });
  2071. if (!response.ok) { throw new Error(data.error || 'Could not read the file.'); }
  2072. this.csvHeaders = Array.isArray(data.headers) ? data.headers : [];
  2073. this.csvTempName = data.temp_name || '';
  2074. this.csvMapping = {};
  2075. this.autoMatchCsvHeaders();
  2076. this.csvStep = 'mapping';
  2077. } catch (err) {
  2078. this.csvError = err.message || 'Could not upload the file.';
  2079. } finally {
  2080. this.isCsvUploading = false;
  2081. }
  2082. },
  2083. autoMatchCsvHeaders() {
  2084. var mapping = {};
  2085. var self = this;
  2086. this.currentAttributes.forEach(function (attr) {
  2087. var norm = self.normalizeCsvHeader(attr.name);
  2088. var match = self.csvHeaders.find(function (h) {
  2089. return self.normalizeCsvHeader(h) === norm;
  2090. });
  2091. if (match) { mapping[attr.name] = match; }
  2092. });
  2093. this.csvMapping = mapping;
  2094. },
  2095. normalizeCsvHeader(s) {
  2096. return s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
  2097. },
  2098. unusedCsvHeaders(attrName) {
  2099. var self = this;
  2100. var used = new Set(
  2101. Object.entries(this.csvMapping)
  2102. .filter(function (pair) { return pair[0] !== attrName && pair[1]; })
  2103. .map(function (pair) { return pair[1]; })
  2104. );
  2105. return this.csvHeaders.filter(function (h) { return !used.has(h); });
  2106. },
  2107. setMapping(attrName, col) {
  2108. var m = Object.assign({}, this.csvMapping);
  2109. if (col === '') { delete m[attrName]; } else { m[attrName] = col; }
  2110. this.csvMapping = m;
  2111. },
  2112. clearMapping(attrName) {
  2113. var m = Object.assign({}, this.csvMapping);
  2114. delete m[attrName];
  2115. this.csvMapping = m;
  2116. },
  2117. buildMappingBody(body) {
  2118. Object.entries(this.csvMapping).forEach(function (pair) {
  2119. body.set('mapping[' + pair[0] + ']', pair[1]);
  2120. });
  2121. },
  2122. async previewCsv() {
  2123. this.isCsvPreviewing = true;
  2124. this.csvError = '';
  2125. try {
  2126. var body = new URLSearchParams();
  2127. body.set('_token', window.__csrf || '');
  2128. body.set('customer_type_id', this.selectedTypeId);
  2129. body.set('temp_name', this.csvTempName);
  2130. this.buildMappingBody(body);
  2131. var response = await fetch('/customers/import/preview', {
  2132. method: 'POST',
  2133. headers: {
  2134. Accept: 'application/json',
  2135. 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
  2136. },
  2137. body: body.toString(),
  2138. });
  2139. var data = await response.json().catch(function () { return {}; });
  2140. if (!response.ok) { throw new Error(data.error || 'Preview failed.'); }
  2141. this.csvPreviewRows = Array.isArray(data.rows) ? data.rows : [];
  2142. this.csvPreviewStats = data.stats || { total: 0, ok: 0, duplicate: 0, empty: 0 };
  2143. this.csvStep = 'preview';
  2144. } catch (err) {
  2145. this.csvError = err.message || 'Preview failed.';
  2146. } finally {
  2147. this.isCsvPreviewing = false;
  2148. }
  2149. },
  2150. async approveCsv() {
  2151. this.isCsvApproving = true;
  2152. this.csvError = '';
  2153. try {
  2154. var body = new URLSearchParams();
  2155. body.set('_token', window.__csrf || '');
  2156. body.set('customer_type_id', this.selectedTypeId);
  2157. body.set('temp_name', this.csvTempName);
  2158. this.buildMappingBody(body);
  2159. var response = await fetch('/customers/import/approve', {
  2160. method: 'POST',
  2161. headers: {
  2162. Accept: 'application/json',
  2163. 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
  2164. },
  2165. body: body.toString(),
  2166. });
  2167. var data = await response.json().catch(function () { return {}; });
  2168. if (!response.ok) { throw new Error(data.error || 'Import failed.'); }
  2169. var inserted = data.inserted || 0;
  2170. var skipped = data.skipped || 0;
  2171. this.csvDoneMessage = 'Successfully imported ' + inserted + ' customer' + (inserted !== 1 ? 's' : '') + '.' +
  2172. (skipped > 0 ? ' Skipped ' + skipped + ' empty row' + (skipped !== 1 ? 's' : '') + '.' : '');
  2173. this.csvApproveErrors = Array.isArray(data.errors) ? data.errors : [];
  2174. this.csvStep = 'done';
  2175. } catch (err) {
  2176. this.csvError = err.message || 'Import failed.';
  2177. } finally {
  2178. this.isCsvApproving = false;
  2179. }
  2180. },
  2181. resetCsvImport() {
  2182. this.csvStep = 'idle';
  2183. this.csvFileSelected = false;
  2184. this.isCsvUploading = false;
  2185. this.isCsvPreviewing = false;
  2186. this.isCsvApproving = false;
  2187. this.csvError = '';
  2188. this.csvHeaders = [];
  2189. this.csvMapping = {};
  2190. this.csvTempName = '';
  2191. this.csvPreviewRows = [];
  2192. this.csvPreviewStats = { total: 0, ok: 0, duplicate: 0, empty: 0 };
  2193. this.csvDoneMessage = '';
  2194. this.csvApproveErrors = [];
  2195. var fi = this.$refs.csvFileInput;
  2196. if (fi) { fi.value = ''; }
  2197. },
  2198. };
  2199. };
  2200. // Unsaved-changes guard — fires beforeunload warning when a .ct-form has been
  2201. // touched but not yet submitted. Delete forms and the logout form are excluded
  2202. // because they use different CSS classes and are intentional navigation.
  2203. (function () {
  2204. function initDirtyFormGuard() {
  2205. var forms = document.querySelectorAll('form.ct-form');
  2206. if (!forms.length) return;
  2207. var dirty = false;
  2208. function markDirty() { dirty = true; }
  2209. function markClean() { dirty = false; }
  2210. forms.forEach(function (form) {
  2211. form.addEventListener('input', markDirty);
  2212. form.addEventListener('change', markDirty);
  2213. form.addEventListener('submit', markClean);
  2214. });
  2215. window.addEventListener('beforeunload', function (e) {
  2216. if (!dirty) return;
  2217. e.preventDefault();
  2218. e.returnValue = '';
  2219. });
  2220. }
  2221. if (document.readyState === 'loading') {
  2222. document.addEventListener('DOMContentLoaded', initDirtyFormGuard);
  2223. } else {
  2224. initDirtyFormGuard();
  2225. }
  2226. }());

Powered by TurnKey Linux.