選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

1313 行
46KB

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

Powered by TurnKey Linux.