Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

1305 linhas
46KB

  1. // ── Shared util ───────────────────────────────────────────────────────────────
  2. function _postDelete(action) {
  3. const form = document.createElement('form');
  4. form.method = 'POST';
  5. form.action = action;
  6. const t = document.createElement('input');
  7. t.type = 'hidden';
  8. t.name = '_token';
  9. t.value = window.__csrf || '';
  10. form.appendChild(t);
  11. document.body.appendChild(form);
  12. form.submit();
  13. }
  14. function _escapeHtml(value) {
  15. return String(value).replace(/[&<>"']/g, function (char) {
  16. return {
  17. '&': '&amp;',
  18. '<': '&lt;',
  19. '>': '&gt;',
  20. '"': '&quot;',
  21. "'": '&#039;',
  22. }[char];
  23. });
  24. }
  25. // ── Campaign Type ─────────────────────────────────────────────────────────────
  26. window.campaignTypeTable = function () {
  27. return {
  28. table: null,
  29. init() {
  30. this.initTable();
  31. },
  32. initTable() {
  33. const el = document.getElementById('campaign-type-table');
  34. if (!el || typeof Tabulator === 'undefined') {
  35. return;
  36. }
  37. this.table = new Tabulator(el, {
  38. ajaxURL: '/campaign-types/data',
  39. layout: 'fitColumns',
  40. responsiveLayout: 'collapse',
  41. pagination: true,
  42. paginationMode: 'local',
  43. paginationSize: 10,
  44. movableColumns: true,
  45. placeholder: 'No campaign types found.',
  46. initialSort: [{ column: 'name', dir: 'asc' }],
  47. columns: [
  48. { title: 'Name', field: 'name', minWidth: 200 },
  49. {
  50. title: 'Attributes',
  51. field: 'attributes_summary',
  52. minWidth: 240,
  53. formatter: function (cell) {
  54. const v = cell.getValue();
  55. return v ? '<span class="attr-summary">' + _escapeHtml(v) + '</span>'
  56. : '<span class="attr-empty">&mdash;</span>';
  57. },
  58. },
  59. { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 },
  60. { title: 'Created', field: 'created_at', minWidth: 160 },
  61. {
  62. title: 'Actions',
  63. field: 'id',
  64. width: 160,
  65. hozAlign: 'center',
  66. headerSort: false,
  67. formatter: function (cell) {
  68. const id = cell.getValue();
  69. return '<a href="/campaign-types/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' +
  70. '<button onclick="window.deleteCampaignType(' + id + ')" class="button button-danger button-sm">Delete</button>';
  71. },
  72. },
  73. ],
  74. });
  75. },
  76. reloadTable() {
  77. if (!this.table) {
  78. this.initTable();
  79. return;
  80. }
  81. this.table.setData('/campaign-types/data');
  82. },
  83. };
  84. };
  85. window.deleteCampaignType = function (id) {
  86. if (!confirm('Delete this campaign type? This cannot be undone.')) {
  87. return;
  88. }
  89. _postDelete('/campaign-types/' + id + '/delete');
  90. };
  91. window.campaignTypeForm = function (initialAttributes) {
  92. return {
  93. attributes: Array.isArray(initialAttributes) ? initialAttributes : [],
  94. dragIndex: null,
  95. dragOverIndex: null,
  96. addAttribute() {
  97. this.attributes.push({ name: '', type: 'text', order: this.attributes.length + 1 });
  98. },
  99. removeAttribute(index) {
  100. this.attributes.splice(index, 1);
  101. this.renumberOrder();
  102. },
  103. renumberOrder() {
  104. this.attributes.forEach(function (attr, i) { attr.order = i + 1; });
  105. },
  106. dragStart(event, index) {
  107. this.dragIndex = index;
  108. event.dataTransfer.effectAllowed = 'move';
  109. },
  110. dragOver(event, index) {
  111. this.dragOverIndex = index;
  112. },
  113. drop(event, index) {
  114. if (this.dragIndex !== null && this.dragIndex !== index) {
  115. var moved = this.attributes.splice(this.dragIndex, 1)[0];
  116. this.attributes.splice(index, 0, moved);
  117. this.renumberOrder();
  118. }
  119. this.dragIndex = null;
  120. this.dragOverIndex = null;
  121. },
  122. dragEnd() {
  123. this.dragIndex = null;
  124. this.dragOverIndex = null;
  125. },
  126. confirmDelete(event) {
  127. if (confirm('Delete this campaign type? This cannot be undone.')) {
  128. event.target.submit();
  129. }
  130. },
  131. };
  132. };
  133. // ── Campaign ──────────────────────────────────────────────────────────────────
  134. window.campaignTable = function () {
  135. return {
  136. table: null,
  137. jobsTable: null,
  138. isLoading: false,
  139. isJobsLoading: false,
  140. errorMessage: '',
  141. jobsErrorMessage: '',
  142. selectedCampaignId: null,
  143. selectedCampaignTitle: '',
  144. init() {
  145. this.loadTable();
  146. },
  147. async loadTable() {
  148. const el = document.getElementById('campaign-table');
  149. if (!el || typeof Tabulator === 'undefined' || this.isLoading) {
  150. return;
  151. }
  152. this.isLoading = true;
  153. this.errorMessage = '';
  154. try {
  155. const response = await fetch('/campaigns/data', {
  156. headers: { Accept: 'application/json' },
  157. });
  158. if (!response.ok) {
  159. throw new Error('Unable to load campaigns.');
  160. }
  161. const rows = await response.json();
  162. const campaignRows = Array.isArray(rows) ? rows : [];
  163. const attributes = this.attributeColumnsForRows(campaignRows);
  164. const tableRows = this.formatRows(campaignRows, attributes);
  165. const columns = this.columnsForAttributes(attributes);
  166. if (!this.table) {
  167. this.table = new Tabulator(el, {
  168. data: tableRows,
  169. layout: 'fitColumns',
  170. responsiveLayout: 'collapse',
  171. pagination: true,
  172. paginationMode: 'local',
  173. paginationSize: 10,
  174. movableColumns: true,
  175. placeholder: 'No campaigns found.',
  176. initialSort: [{ column: 'campaign_type_name', dir: 'asc' }],
  177. columns: columns,
  178. });
  179. this.table.on('rowClick', (event, row) => this.goToCampaignJobs(event, row));
  180. } else {
  181. this.table.setColumns(columns);
  182. this.table.setData(tableRows);
  183. }
  184. } catch (error) {
  185. this.errorMessage = error.message || 'Unable to load campaigns.';
  186. } finally {
  187. this.isLoading = false;
  188. }
  189. },
  190. attributeColumnsForRows(rows) {
  191. const attributes = [];
  192. rows.forEach((row) => {
  193. this.normalizeAttributes(row.campaign_type_attributes || []).forEach((attr) => {
  194. if (!attributes.some((existing) => existing.name === attr.name)) {
  195. attributes.push(attr);
  196. }
  197. });
  198. Object.keys(row.attribute_values || {}).forEach((name) => {
  199. if (!attributes.some((existing) => existing.name === name)) {
  200. attributes.push({ name: name, type: 'text', order: attributes.length + 1 });
  201. }
  202. });
  203. });
  204. return attributes;
  205. },
  206. normalizeAttributes(attributes) {
  207. return attributes
  208. .filter((attr) => attr && attr.name)
  209. .slice()
  210. .sort((a, b) => (a.order || 0) - (b.order || 0));
  211. },
  212. formatRows(rows, attributes) {
  213. return rows.map((row) => {
  214. const attributeValues = row.attribute_values || {};
  215. const tableRow = {
  216. id: row.id,
  217. campaign_type_name: row.campaign_type_name || '',
  218. created_at: row.created_at || '',
  219. };
  220. attributes.forEach((attr, index) => {
  221. tableRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? '');
  222. });
  223. return tableRow;
  224. });
  225. },
  226. formatAttributeValue(value) {
  227. if (value === null || value === undefined) {
  228. return '';
  229. }
  230. if (Array.isArray(value) || typeof value === 'object') {
  231. return JSON.stringify(value);
  232. }
  233. return String(value);
  234. },
  235. columnsForAttributes(attributes) {
  236. const columns = [
  237. {
  238. title: 'Campaign Type',
  239. field: 'campaign_type_name',
  240. minWidth: 160,
  241. headerFilter: 'input',
  242. },
  243. ];
  244. attributes.forEach((attr, index) => {
  245. columns.push({
  246. title: attr.name,
  247. field: 'attr_' + index,
  248. minWidth: 150,
  249. headerFilter: 'input',
  250. formatter: function (cell) {
  251. const value = cell.getValue();
  252. return value ? _escapeHtml(value) : '<span class="attr-empty">&mdash;</span>';
  253. },
  254. });
  255. });
  256. columns.push(
  257. { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' },
  258. {
  259. title: 'Actions',
  260. field: 'id',
  261. width: 230,
  262. hozAlign: 'center',
  263. headerSort: false,
  264. formatter: function (cell) {
  265. const id = cell.getValue();
  266. return '<a href="/campaigns/' + id + '/jobs" class="button button-primary button-sm">Jobs</a> ' +
  267. '<a href="/campaigns/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' +
  268. '<button onclick="window.deleteCampaign(' + id + ')" class="button button-danger button-sm">Delete</button>';
  269. },
  270. }
  271. );
  272. return columns;
  273. },
  274. goToCampaignJobs(event, row) {
  275. const target = event.target;
  276. if (target instanceof Element && target.closest('a, button')) {
  277. return;
  278. }
  279. window.location.href = '/campaigns/' + encodeURIComponent(row.getData().id) + '/jobs';
  280. },
  281. reloadTable() {
  282. this.loadTable();
  283. },
  284. openCampaignJobs(campaign) {
  285. this.selectedCampaignId = campaign.id;
  286. this.selectedCampaignTitle = 'Campaign #' + campaign.id + ' - ' + campaign.campaign_type_name;
  287. this.$nextTick(() => this.loadJobsTable());
  288. },
  289. async reloadJobsTable() {
  290. if (!this.selectedCampaignId) {
  291. return;
  292. }
  293. await this.loadJobsTable();
  294. },
  295. closeJobsTable() {
  296. this.selectedCampaignId = null;
  297. this.selectedCampaignTitle = '';
  298. this.jobsErrorMessage = '';
  299. if (this.jobsTable && typeof this.jobsTable.destroy === 'function') {
  300. this.jobsTable.destroy();
  301. }
  302. this.jobsTable = null;
  303. },
  304. async loadJobsTable() {
  305. const el = document.getElementById('campaign-jobs-drilldown-table');
  306. if (!el || typeof Tabulator === 'undefined' || this.isJobsLoading) {
  307. return;
  308. }
  309. this.isJobsLoading = true;
  310. this.jobsErrorMessage = '';
  311. try {
  312. const response = await fetch('/campaigns/' + encodeURIComponent(this.selectedCampaignId) + '/jobs/data', {
  313. headers: { Accept: 'application/json' },
  314. });
  315. if (!response.ok) {
  316. throw new Error('Unable to load campaign jobs.');
  317. }
  318. const rows = await response.json();
  319. const jobRows = Array.isArray(rows) ? rows : [];
  320. const attributes = this.jobAttributeColumnsForRows(jobRows);
  321. const tableRows = this.formatJobRows(jobRows, attributes);
  322. const columns = this.jobColumnsForAttributes(attributes);
  323. if (!this.jobsTable) {
  324. this.jobsTable = new Tabulator(el, {
  325. data: tableRows,
  326. layout: 'fitColumns',
  327. responsiveLayout: 'collapse',
  328. pagination: true,
  329. paginationMode: 'local',
  330. paginationSize: 10,
  331. movableColumns: true,
  332. placeholder: 'No jobs found for this campaign.',
  333. initialSort: [{ column: 'job_type_name', dir: 'asc' }],
  334. columns: columns,
  335. });
  336. } else {
  337. this.jobsTable.setColumns(columns);
  338. this.jobsTable.setData(tableRows);
  339. }
  340. } catch (error) {
  341. this.jobsErrorMessage = error.message || 'Unable to load campaign jobs.';
  342. } finally {
  343. this.isJobsLoading = false;
  344. }
  345. },
  346. jobAttributeColumnsForRows(rows) {
  347. const attributes = [];
  348. rows.forEach((row) => {
  349. this.normalizeAttributes(row.job_type_attributes || []).forEach((attr) => {
  350. if (!attributes.some((existing) => existing.name === attr.name)) {
  351. attributes.push(attr);
  352. }
  353. });
  354. Object.keys(row.attribute_values || {}).forEach((name) => {
  355. if (!attributes.some((existing) => existing.name === name)) {
  356. attributes.push({ name: name, type: 'text', order: attributes.length + 1 });
  357. }
  358. });
  359. });
  360. return attributes;
  361. },
  362. formatJobRows(rows, attributes) {
  363. return rows.map((row) => {
  364. const attributeValues = row.attribute_values || {};
  365. const tableRow = {
  366. id: row.id,
  367. campaign_id: row.campaign_id || '',
  368. job_type_id: row.job_type_id || '',
  369. job_type_name: row.job_type_name || '',
  370. created_at: row.created_at || '',
  371. updated_at: row.updated_at || '',
  372. edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit',
  373. };
  374. attributes.forEach((attr, index) => {
  375. tableRow['job_attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? '');
  376. });
  377. return tableRow;
  378. });
  379. },
  380. jobColumnsForAttributes(attributes) {
  381. const columns = [
  382. { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' },
  383. { title: 'Campaign ID', field: 'campaign_id', width: 120, hozAlign: 'center', headerFilter: 'input' },
  384. { title: 'Job Type ID', field: 'job_type_id', width: 120, hozAlign: 'center', headerFilter: 'input' },
  385. { title: 'Job Type', field: 'job_type_name', minWidth: 160, headerFilter: 'input' },
  386. ];
  387. attributes.forEach((attr, index) => {
  388. columns.push({
  389. title: attr.name,
  390. field: 'job_attr_' + index,
  391. minWidth: 150,
  392. headerFilter: 'input',
  393. formatter: function (cell) {
  394. const value = cell.getValue();
  395. return value ? _escapeHtml(value) : '<span class="attr-empty">&mdash;</span>';
  396. },
  397. });
  398. });
  399. columns.push(
  400. { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' },
  401. { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' },
  402. {
  403. title: 'Actions',
  404. field: 'edit_url',
  405. width: 90,
  406. hozAlign: 'center',
  407. headerSort: false,
  408. formatter: function (cell) {
  409. return '<a href="' + _escapeHtml(cell.getValue()) + '" class="button button-secondary button-sm">Edit</a>';
  410. },
  411. }
  412. );
  413. return columns;
  414. },
  415. };
  416. };
  417. window.deleteCampaign = function (id) {
  418. if (!confirm('Delete this campaign? This cannot be undone.')) {
  419. return;
  420. }
  421. _postDelete('/campaigns/' + id + '/delete');
  422. };
  423. window.campaignJobsPageTable = function (campaignId, jobTypes) {
  424. return {
  425. table: null,
  426. jobTypes: Array.isArray(jobTypes) ? jobTypes : [],
  427. isLoading: false,
  428. isConnecting: false,
  429. isImporting: false,
  430. errorMessage: '',
  431. importSheetUrl: '',
  432. sheets: [],
  433. selectedSheetGid: '',
  434. selectedImportJobTypeId: '0',
  435. importMessage: '',
  436. importErrorMessage: '',
  437. // File upload state
  438. importSource: 'sheets',
  439. fileSelected: false,
  440. fileTempName: '',
  441. fileSheets: [],
  442. selectedFileSheetGid: '',
  443. selectedFileJobTypeId: '0',
  444. isLoadingFile: false,
  445. isImportingFile: false,
  446. init() {
  447. this.loadTable();
  448. },
  449. dataUrl() {
  450. return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/data';
  451. },
  452. sheetsUrl() {
  453. return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import/sheets';
  454. },
  455. importUrl() {
  456. return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import';
  457. },
  458. async loadTable() {
  459. const el = document.getElementById('campaign-jobs-page-table');
  460. if (!el || typeof Tabulator === 'undefined' || this.isLoading) {
  461. return;
  462. }
  463. this.isLoading = true;
  464. this.errorMessage = '';
  465. try {
  466. const response = await fetch(this.dataUrl(), {
  467. headers: { Accept: 'application/json' },
  468. });
  469. if (!response.ok) {
  470. throw new Error('Unable to load campaign jobs.');
  471. }
  472. const rows = await response.json();
  473. const jobRows = Array.isArray(rows) ? rows : [];
  474. const attributes = this.attributeColumnsForRows(jobRows);
  475. const tableRows = this.formatRows(jobRows, attributes);
  476. const columns = this.columnsForAttributes(attributes);
  477. if (!this.table) {
  478. this.table = new Tabulator(el, {
  479. data: tableRows,
  480. layout: 'fitData',
  481. maxHeight: '65vh',
  482. pagination: true,
  483. paginationMode: 'local',
  484. paginationSize: 10,
  485. movableColumns: true,
  486. placeholder: 'No jobs found for this campaign.',
  487. initialSort: [{ column: 'job_type_name', dir: 'asc' }],
  488. columns: columns,
  489. });
  490. } else {
  491. this.table.setColumns(columns);
  492. this.table.setData(tableRows);
  493. }
  494. } catch (error) {
  495. this.errorMessage = error.message || 'Unable to load campaign jobs.';
  496. } finally {
  497. this.isLoading = false;
  498. }
  499. },
  500. attributeColumnsForRows(rows) {
  501. const attributes = [];
  502. rows.forEach((row) => {
  503. this.normalizeAttributes(row.job_type_attributes || []).forEach((attr) => {
  504. if (!attributes.some((existing) => existing.name === attr.name)) {
  505. attributes.push(attr);
  506. }
  507. });
  508. Object.keys(row.attribute_values || {}).forEach((name) => {
  509. if (!attributes.some((existing) => existing.name === name)) {
  510. attributes.push({ name: name, type: 'text', order: attributes.length + 1 });
  511. }
  512. });
  513. });
  514. return attributes;
  515. },
  516. normalizeAttributes(attributes) {
  517. return attributes
  518. .filter((attr) => attr && attr.name)
  519. .slice()
  520. .sort((a, b) => (a.order || 0) - (b.order || 0));
  521. },
  522. formatRows(rows, attributes) {
  523. return rows.map((row) => {
  524. const attributeValues = row.attribute_values || {};
  525. const tableRow = {
  526. id: row.id,
  527. campaign_id: row.campaign_id || '',
  528. campaign_type_name: row.campaign_type_name || '',
  529. job_type_id: row.job_type_id || '',
  530. job_type_name: row.job_type_name || '',
  531. created_at: row.created_at || '',
  532. updated_at: row.updated_at || '',
  533. edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit',
  534. };
  535. attributes.forEach((attr, index) => {
  536. tableRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? '');
  537. });
  538. return tableRow;
  539. });
  540. },
  541. formatAttributeValue(value) {
  542. if (value === null || value === undefined) {
  543. return '';
  544. }
  545. if (Array.isArray(value) || typeof value === 'object') {
  546. return JSON.stringify(value);
  547. }
  548. return String(value);
  549. },
  550. columnsForAttributes(attributes) {
  551. const columns = [
  552. { title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' },
  553. { title: 'Campaign ID', field: 'campaign_id', width: 120, hozAlign: 'center', headerFilter: 'input' },
  554. { title: 'Campaign Type', field: 'campaign_type_name', minWidth: 160, headerFilter: 'input' },
  555. { title: 'Job Type ID', field: 'job_type_id', width: 120, hozAlign: 'center', headerFilter: 'input' },
  556. { title: 'Job Type', field: 'job_type_name', minWidth: 160, headerFilter: 'input' },
  557. ];
  558. attributes.forEach((attr, index) => {
  559. columns.push({
  560. title: attr.name,
  561. field: 'attr_' + index,
  562. minWidth: 150,
  563. headerFilter: 'input',
  564. formatter: function (cell) {
  565. const value = cell.getValue();
  566. return value ? _escapeHtml(value) : '<span class="attr-empty">&mdash;</span>';
  567. },
  568. });
  569. });
  570. columns.push(
  571. { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' },
  572. { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' },
  573. {
  574. title: 'Actions',
  575. field: 'edit_url',
  576. width: 90,
  577. hozAlign: 'center',
  578. headerSort: false,
  579. formatter: function (cell) {
  580. return '<a href="' + _escapeHtml(cell.getValue()) + '" class="button button-secondary button-sm">Edit</a>';
  581. },
  582. }
  583. );
  584. return columns;
  585. },
  586. reloadTable() {
  587. this.loadTable();
  588. },
  589. async connectGoogleSheet() {
  590. this.isConnecting = true;
  591. this.importMessage = '';
  592. this.importErrorMessage = '';
  593. this.sheets = [];
  594. this.selectedSheetGid = '';
  595. try {
  596. const data = await this.postImportForm(this.sheetsUrl(), {
  597. sheet_url: this.importSheetUrl,
  598. });
  599. this.sheets = Array.isArray(data.sheets) ? data.sheets : [];
  600. if (this.sheets.length > 0) {
  601. this.selectedSheetGid = this.sheets[0].gid;
  602. this.importMessage = 'Connected. Select a sheet and job type to import.';
  603. } else {
  604. this.importErrorMessage = 'No sheets were found in that Google Sheets file.';
  605. }
  606. } catch (error) {
  607. this.importErrorMessage = error.message || 'Unable to connect to Google Sheets.';
  608. } finally {
  609. this.isConnecting = false;
  610. }
  611. },
  612. async importGoogleSheet() {
  613. this.isImporting = true;
  614. this.importMessage = '';
  615. this.importErrorMessage = '';
  616. try {
  617. const data = await this.postImportForm(this.importUrl(), {
  618. sheet_url: this.importSheetUrl,
  619. sheet_gid: this.selectedSheetGid,
  620. job_type_id: this.selectedImportJobTypeId,
  621. });
  622. const matched = Array.isArray(data.matched_attributes) ? data.matched_attributes.join(', ') : '';
  623. this.importMessage = 'Imported ' + data.imported + ' jobs. Skipped ' + data.skipped + ' empty rows.' +
  624. (matched ? ' Matched: ' + matched + '.' : '');
  625. await this.loadTable();
  626. } catch (error) {
  627. this.importErrorMessage = error.message || 'Unable to import Google Sheet.';
  628. } finally {
  629. this.isImporting = false;
  630. }
  631. },
  632. async postImportForm(url, fields) {
  633. const body = new URLSearchParams();
  634. body.set('_token', window.__csrf || '');
  635. Object.keys(fields).forEach((key) => {
  636. body.set(key, fields[key] || '');
  637. });
  638. const response = await fetch(url, {
  639. method: 'POST',
  640. headers: {
  641. Accept: 'application/json',
  642. 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
  643. },
  644. body: body.toString(),
  645. });
  646. const data = await response.json().catch(() => ({}));
  647. if (!response.ok) {
  648. throw new Error(data.error || 'Import failed.');
  649. }
  650. return data;
  651. },
  652. // ── File upload methods ───────────────────────────────────────────────
  653. onFileSelect(event) {
  654. this.fileSelected = event.target.files && event.target.files.length > 0;
  655. this.fileTempName = '';
  656. this.fileSheets = [];
  657. this.selectedFileSheetGid = '';
  658. this.importMessage = '';
  659. this.importErrorMessage = '';
  660. },
  661. async loadFileSheets() {
  662. this.isLoadingFile = true;
  663. this.importMessage = '';
  664. this.importErrorMessage = '';
  665. this.fileTempName = '';
  666. this.fileSheets = [];
  667. this.selectedFileSheetGid = '';
  668. try {
  669. const fileInput = this.$refs.fileInput;
  670. if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
  671. throw new Error('No file selected.');
  672. }
  673. const form = new FormData();
  674. form.set('_token', window.__csrf || '');
  675. form.set('import_file', fileInput.files[0]);
  676. const url = '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import/file/sheets';
  677. const response = await fetch(url, {
  678. method: 'POST',
  679. headers: { Accept: 'application/json' },
  680. body: form,
  681. });
  682. const data = await response.json().catch(() => ({}));
  683. if (!response.ok) {
  684. throw new Error(data.error || 'Could not read the file.');
  685. }
  686. this.fileTempName = data.temp_file || '';
  687. this.fileSheets = Array.isArray(data.sheets) ? data.sheets : [];
  688. if (this.fileSheets.length > 0) {
  689. this.selectedFileSheetGid = this.fileSheets[0].gid;
  690. this.importMessage = 'File loaded. Select a sheet and job type to import.';
  691. } else {
  692. this.importErrorMessage = 'No sheets were found in the uploaded file.';
  693. }
  694. } catch (error) {
  695. this.importErrorMessage = error.message || 'Could not read the file.';
  696. } finally {
  697. this.isLoadingFile = false;
  698. }
  699. },
  700. async importFile() {
  701. this.isImportingFile = true;
  702. this.importMessage = '';
  703. this.importErrorMessage = '';
  704. try {
  705. const url = '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/import/file';
  706. const data = await this.postImportForm(url, {
  707. temp_file: this.fileTempName,
  708. sheet_gid: this.selectedFileSheetGid,
  709. job_type_id: this.selectedFileJobTypeId,
  710. });
  711. const matched = Array.isArray(data.matched_attributes) ? data.matched_attributes.join(', ') : '';
  712. this.importMessage = 'Imported ' + data.imported + ' jobs. Skipped ' + data.skipped + ' empty rows.' +
  713. (matched ? ' Matched: ' + matched + '.' : '');
  714. this.fileTempName = '';
  715. await this.loadTable();
  716. } catch (error) {
  717. this.importErrorMessage = error.message || 'Import failed.';
  718. } finally {
  719. this.isImportingFile = false;
  720. }
  721. },
  722. };
  723. };
  724. window.campaignForm = function (types, initialTypeId, initialValues) {
  725. return {
  726. types: types,
  727. selectedTypeId: String(initialTypeId || ''),
  728. attributeValues: Object.assign({}, initialValues || {}),
  729. get currentType() {
  730. var id = this.selectedTypeId;
  731. if (!id) return null;
  732. return this.types.find(function (t) { return String(t.id) === String(id); }) || null;
  733. },
  734. get currentAttributes() {
  735. if (!this.currentType) return [];
  736. return this.currentType.attributes.slice().sort(function (a, b) {
  737. return (a.order || 0) - (b.order || 0);
  738. });
  739. },
  740. onTypeChange() {
  741. this.attributeValues = {};
  742. },
  743. inputType(attrType) {
  744. return ['number', 'date'].includes(attrType) ? attrType : 'text';
  745. },
  746. confirmDelete(event) {
  747. if (confirm('Delete this campaign? This cannot be undone.')) {
  748. event.target.submit();
  749. }
  750. },
  751. };
  752. };
  753. // Campaign Jobs
  754. window.campaignJobsTable = function (campaignId) {
  755. return {
  756. tables: {},
  757. groups: [],
  758. isVisible: false,
  759. isLoading: false,
  760. hasLoaded: false,
  761. errorMessage: '',
  762. dataUrl() {
  763. return '/campaigns/' + encodeURIComponent(campaignId) + '/jobs/data';
  764. },
  765. async showTable() {
  766. this.isVisible = true;
  767. if (!this.hasLoaded) {
  768. await this.loadGroups();
  769. }
  770. },
  771. hideTable() {
  772. this.isVisible = false;
  773. },
  774. async reloadTable() {
  775. this.isVisible = true;
  776. await this.loadGroups();
  777. },
  778. async loadGroups() {
  779. this.isLoading = true;
  780. this.errorMessage = '';
  781. this.destroyTables();
  782. try {
  783. const response = await fetch(this.dataUrl(), {
  784. headers: { Accept: 'application/json' },
  785. });
  786. if (!response.ok) {
  787. throw new Error('Unable to load campaign jobs.');
  788. }
  789. const rows = await response.json();
  790. this.groups = this.groupRows(Array.isArray(rows) ? rows : []);
  791. this.hasLoaded = true;
  792. this.$nextTick(() => this.initTables());
  793. } catch (error) {
  794. this.groups = [];
  795. this.errorMessage = error.message || 'Unable to load campaign jobs.';
  796. } finally {
  797. this.isLoading = false;
  798. }
  799. },
  800. groupRows(rows) {
  801. const groups = {};
  802. rows.forEach((row) => {
  803. const id = String(row.job_type_id || 0);
  804. if (!groups[id]) {
  805. groups[id] = {
  806. id: id,
  807. elementId: 'campaign-jobs-table-' + id,
  808. name: row.job_type_name || 'Job Type #' + id,
  809. attributes: this.normalizeAttributes(row.job_type_attributes || []),
  810. rows: [],
  811. };
  812. }
  813. const attributeValues = row.attribute_values || {};
  814. this.ensureAttributeColumns(groups[id], attributeValues);
  815. const gridRow = {
  816. id: row.id,
  817. created_at: row.created_at || '',
  818. edit_url: '/jobs/' + encodeURIComponent(row.id) + '/edit',
  819. };
  820. groups[id].attributes.forEach((attr, index) => {
  821. gridRow['attr_' + index] = this.formatAttributeValue(attributeValues[attr.name] ?? '');
  822. });
  823. groups[id].rows.push(gridRow);
  824. });
  825. return Object.values(groups).sort((a, b) => a.name.localeCompare(b.name));
  826. },
  827. ensureAttributeColumns(group, attributeValues) {
  828. Object.keys(attributeValues).forEach((name) => {
  829. const exists = group.attributes.some((attr) => attr.name === name);
  830. if (!exists) {
  831. group.attributes.push({
  832. name: name,
  833. type: 'text',
  834. order: group.attributes.length + 1,
  835. });
  836. }
  837. });
  838. },
  839. normalizeAttributes(attributes) {
  840. return attributes
  841. .filter((attr) => attr && attr.name)
  842. .slice()
  843. .sort((a, b) => (a.order || 0) - (b.order || 0));
  844. },
  845. formatAttributeValue(value) {
  846. if (value === null || value === undefined) {
  847. return '';
  848. }
  849. if (Array.isArray(value) || typeof value === 'object') {
  850. return JSON.stringify(value);
  851. }
  852. return String(value);
  853. },
  854. initTables() {
  855. if (typeof Tabulator === 'undefined') {
  856. return;
  857. }
  858. this.groups.forEach((group) => {
  859. const el = document.getElementById(group.elementId);
  860. if (!el || this.tables[group.id]) {
  861. return;
  862. }
  863. this.tables[group.id] = new Tabulator(el, {
  864. data: group.rows,
  865. layout: 'fitColumns',
  866. responsiveLayout: 'collapse',
  867. pagination: true,
  868. paginationMode: 'local',
  869. paginationSize: 5,
  870. movableColumns: true,
  871. placeholder: 'No jobs found for this job type.',
  872. initialSort: [{ column: 'created_at', dir: 'desc' }],
  873. columns: this.columnsForGroup(group),
  874. });
  875. });
  876. },
  877. columnsForGroup(group) {
  878. const columns = group.attributes.map((attr, index) => ({
  879. title: attr.name,
  880. field: 'attr_' + index,
  881. minWidth: 150,
  882. formatter: function (cell) {
  883. const value = cell.getValue();
  884. return value ? _escapeHtml(value) : '<span class="attr-empty">&mdash;</span>';
  885. },
  886. }));
  887. if (columns.length === 0) {
  888. columns.push({ title: 'Job ID', field: 'id', width: 90, hozAlign: 'center' });
  889. }
  890. columns.push(
  891. { title: 'Created', field: 'created_at', minWidth: 160 },
  892. {
  893. title: 'Actions',
  894. field: 'edit_url',
  895. width: 90,
  896. hozAlign: 'center',
  897. headerSort: false,
  898. formatter: function (cell) {
  899. return '<a href="' + _escapeHtml(cell.getValue()) + '" class="button button-secondary button-sm">Edit</a>';
  900. },
  901. }
  902. );
  903. return columns;
  904. },
  905. destroyTables() {
  906. Object.values(this.tables).forEach((table) => {
  907. if (table && typeof table.destroy === 'function') {
  908. table.destroy();
  909. }
  910. });
  911. this.tables = {};
  912. },
  913. };
  914. };
  915. // Job Type
  916. window.jobTypeTable = function () {
  917. return {
  918. table: null,
  919. init() {
  920. this.initTable();
  921. },
  922. initTable() {
  923. const el = document.getElementById('job-type-table');
  924. if (!el || typeof Tabulator === 'undefined') {
  925. return;
  926. }
  927. this.table = new Tabulator(el, {
  928. ajaxURL: '/job-types/data',
  929. layout: 'fitColumns',
  930. responsiveLayout: 'collapse',
  931. pagination: true,
  932. paginationMode: 'local',
  933. paginationSize: 10,
  934. movableColumns: true,
  935. placeholder: 'No job types found.',
  936. initialSort: [{ column: 'name', dir: 'asc' }],
  937. columns: [
  938. { title: 'Name', field: 'name', minWidth: 200 },
  939. {
  940. title: 'Attributes',
  941. field: 'attributes_summary',
  942. minWidth: 240,
  943. formatter: function (cell) {
  944. const v = cell.getValue();
  945. return v ? '<span class="attr-summary">' + _escapeHtml(v) + '</span>'
  946. : '<span class="attr-empty">&mdash;</span>';
  947. },
  948. },
  949. { title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 },
  950. { title: 'Created', field: 'created_at', minWidth: 160 },
  951. {
  952. title: 'Actions',
  953. field: 'id',
  954. width: 160,
  955. hozAlign: 'center',
  956. headerSort: false,
  957. formatter: function (cell) {
  958. const id = cell.getValue();
  959. return '<a href="/job-types/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' +
  960. '<button onclick="window.deleteJobType(' + id + ')" class="button button-danger button-sm">Delete</button>';
  961. },
  962. },
  963. ],
  964. });
  965. },
  966. reloadTable() {
  967. if (!this.table) {
  968. this.initTable();
  969. return;
  970. }
  971. this.table.setData('/job-types/data');
  972. },
  973. };
  974. };
  975. window.deleteJobType = function (id) {
  976. if (!confirm('Delete this job type? This cannot be undone.')) {
  977. return;
  978. }
  979. _postDelete('/job-types/' + id + '/delete');
  980. };
  981. window.jobTypeForm = function (initialAttributes) {
  982. return {
  983. attributes: Array.isArray(initialAttributes) ? initialAttributes : [],
  984. dragIndex: null,
  985. dragOverIndex: null,
  986. addAttribute() {
  987. this.attributes.push({ name: '', type: 'text', order: this.attributes.length + 1 });
  988. },
  989. removeAttribute(index) {
  990. this.attributes.splice(index, 1);
  991. this.renumberOrder();
  992. },
  993. renumberOrder() {
  994. this.attributes.forEach(function (attr, i) { attr.order = i + 1; });
  995. },
  996. dragStart(event, index) {
  997. this.dragIndex = index;
  998. event.dataTransfer.effectAllowed = 'move';
  999. },
  1000. dragOver(event, index) {
  1001. this.dragOverIndex = index;
  1002. },
  1003. drop(event, index) {
  1004. if (this.dragIndex !== null && this.dragIndex !== index) {
  1005. var moved = this.attributes.splice(this.dragIndex, 1)[0];
  1006. this.attributes.splice(index, 0, moved);
  1007. this.renumberOrder();
  1008. }
  1009. this.dragIndex = null;
  1010. this.dragOverIndex = null;
  1011. },
  1012. dragEnd() {
  1013. this.dragIndex = null;
  1014. this.dragOverIndex = null;
  1015. },
  1016. confirmDelete(event) {
  1017. if (confirm('Delete this job type? This cannot be undone.')) {
  1018. event.target.submit();
  1019. }
  1020. },
  1021. };
  1022. };
  1023. // ── Job ───────────────────────────────────────────────────────────────────────
  1024. window.jobTable = function () {
  1025. return {
  1026. table: null,
  1027. init() {
  1028. this.initTable();
  1029. },
  1030. initTable() {
  1031. const el = document.getElementById('job-table');
  1032. if (!el || typeof Tabulator === 'undefined') {
  1033. return;
  1034. }
  1035. this.table = new Tabulator(el, {
  1036. ajaxURL: '/jobs/data',
  1037. layout: 'fitColumns',
  1038. responsiveLayout: 'collapse',
  1039. pagination: true,
  1040. paginationMode: 'local',
  1041. paginationSize: 10,
  1042. movableColumns: true,
  1043. placeholder: 'No jobs found.',
  1044. initialSort: [{ column: 'job_type_name', dir: 'asc' }],
  1045. columns: [
  1046. { title: 'Campaign', field: 'campaign_type_name', minWidth: 160 },
  1047. { title: 'Job Type', field: 'job_type_name', minWidth: 160 },
  1048. {
  1049. title: 'Attributes',
  1050. field: 'attributes_summary',
  1051. minWidth: 220,
  1052. formatter: function (cell) {
  1053. const v = cell.getValue();
  1054. return v ? '<span class="attr-summary">' + _escapeHtml(v) + '</span>'
  1055. : '<span class="attr-empty">&mdash;</span>';
  1056. },
  1057. },
  1058. { title: 'Created', field: 'created_at', minWidth: 160 },
  1059. {
  1060. title: 'Actions',
  1061. field: 'id',
  1062. width: 160,
  1063. hozAlign: 'center',
  1064. headerSort: false,
  1065. formatter: function (cell) {
  1066. const id = cell.getValue();
  1067. return '<a href="/jobs/' + id + '/edit" class="button button-secondary button-sm">Edit</a> ' +
  1068. '<button onclick="window.deleteJob(' + id + ')" class="button button-danger button-sm">Delete</button>';
  1069. },
  1070. },
  1071. ],
  1072. });
  1073. },
  1074. reloadTable() {
  1075. if (!this.table) {
  1076. this.initTable();
  1077. return;
  1078. }
  1079. this.table.setData('/jobs/data');
  1080. },
  1081. };
  1082. };
  1083. window.deleteJob = function (id) {
  1084. if (!confirm('Delete this job? This cannot be undone.')) {
  1085. return;
  1086. }
  1087. _postDelete('/jobs/' + id + '/delete');
  1088. };
  1089. window.jobForm = function (jobTypes, initialTypeId, initialValues) {
  1090. return {
  1091. jobTypes: jobTypes,
  1092. selectedTypeId: String(initialTypeId || ''),
  1093. attributeValues: Object.assign({}, initialValues || {}),
  1094. get currentType() {
  1095. var id = this.selectedTypeId;
  1096. if (!id) return null;
  1097. return this.jobTypes.find(function (t) { return String(t.id) === String(id); }) || null;
  1098. },
  1099. get currentAttributes() {
  1100. if (!this.currentType) return [];
  1101. return this.currentType.attributes.slice().sort(function (a, b) {
  1102. return (a.order || 0) - (b.order || 0);
  1103. });
  1104. },
  1105. onTypeChange() {
  1106. this.attributeValues = {};
  1107. },
  1108. inputType(attrType) {
  1109. return ['number', 'date'].includes(attrType) ? attrType : 'text';
  1110. },
  1111. confirmDelete(event) {
  1112. if (confirm('Delete this job? This cannot be undone.')) {
  1113. event.target.submit();
  1114. }
  1115. },
  1116. };
  1117. };

Powered by TurnKey Linux.