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

2433 Zeilen
91KB

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

Powered by TurnKey Linux.