Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

2569 строки
95KB

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

Powered by TurnKey Linux.