{{ errors.search }}
{{ question.question }}
@@ -30,7 +30,10 @@ const emit = defineEmits<{ diff --git a/src/components/pagination/paginatedList.vue b/src/components/pagination/paginatedList.vue index 74de20ed..22e346eb 100644 --- a/src/components/pagination/paginatedList.vue +++ b/src/components/pagination/paginatedList.vue @@ -488,8 +488,10 @@ export const orderBy = globalInstance.orderBy; export const orderDirection = globalInstance.orderDirection; export const hideSearchField = globalInstance.hideSearchField; export const isLoading = globalInstance.isLoading; +export const isExporting = globalInstance.isExporting; export const latestSearch = globalInstance.latestSearch; export const additionalQueryParameters = globalInstance.additionalQueryParameters; +export const exportTransform = globalInstance.exportTransform; export const setHideSearchField = globalInstance.setHideSearchField; export const setOrder = globalInstance.setOrder; @@ -511,5 +513,10 @@ export const clearAdditionalQueryParameters = globalInstance.clearAdditionalQuer export const paginatedGetRequest = globalInstance.paginatedGetRequest; export const loadList = globalInstance.loadList; export const getFilter = globalInstance.getFilter; +export const fetchPageForExport = globalInstance.fetchPageForExport; +export const fetchAllPagesForExport = globalInstance.fetchAllPagesForExport; +export const setExportTransform = globalInstance.setExportTransform; +export const clearExportTransform = globalInstance.clearExportTransform; +export const exportToExcel = globalInstance.exportToExcel; diff --git a/src/composables/useSelfServeLogic.js b/src/composables/useSelfServeLogic.js index c278ab94..69d1350a 100644 --- a/src/composables/useSelfServeLogic.js +++ b/src/composables/useSelfServeLogic.js @@ -56,39 +56,104 @@ const buildAnswersMap = (questions) => questions.reduce((accumulator, question) return accumulator; }, {}); -const mergeQuestionSets = (existingQuestions, incomingQuestions) => { +const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object || {}, key); + +const isBooleanAnswer = (value) => value === true || value === false; + +const sortByOrderPriority = (list) => [...list].sort((a, b) => { + const priorityDiff = parseInt(a?.order_priority ?? 0) - parseInt(b?.order_priority ?? 0); + if (priorityDiff !== 0) { + return priorityDiff; + } + return parseInt(a?.id ?? 0) - parseInt(b?.id ?? 0); +}); + +const mergeQuestion = (existingQuestion, incomingQuestion, incomingRaw = {}) => { + if (!existingQuestion) { + return incomingQuestion; + } + + const shouldUseIncomingAnswer = hasOwn(incomingRaw, "answer") + && (isBooleanAnswer(incomingQuestion.answer) || !isBooleanAnswer(existingQuestion.answer)); + + return { + ...existingQuestion, + ...incomingQuestion, + question: hasOwn(incomingRaw, "question") ? incomingQuestion.question : existingQuestion.question, + description: hasOwn(incomingRaw, "description") ? incomingQuestion.description : existingQuestion.description, + condition_id: hasOwn(incomingRaw, "condition_id") ? incomingQuestion.condition_id : existingQuestion.condition_id, + order_priority: hasOwn(incomingRaw, "order_priority") ? incomingQuestion.order_priority : existingQuestion.order_priority, + answer: shouldUseIncomingAnswer ? incomingQuestion.answer : existingQuestion.answer, + answered_at: hasOwn(incomingRaw, "answered_at") ? incomingQuestion.answered_at : existingQuestion.answered_at, + }; +}; + +const mergeQuestionSets = (existingQuestions, incomingQuestions, incomingRawQuestions = []) => { if (!Array.isArray(existingQuestions) || existingQuestions.length === 0) { - return [...incomingQuestions].sort((a, b) => a.order_priority - b.order_priority); + return sortByOrderPriority(incomingQuestions); } if (!Array.isArray(incomingQuestions) || incomingQuestions.length === 0) { - return [...existingQuestions].sort((a, b) => a.order_priority - b.order_priority); + return sortByOrderPriority(existingQuestions); } - const incomingMap = incomingQuestions.reduce((accumulator, question) => { - accumulator[question.id] = question; + const incomingMap = incomingQuestions.reduce((accumulator, question, index) => { + accumulator[question.id] = { + question, + raw: incomingRawQuestions[index] || {}, + }; return accumulator; }, {}); const merged = existingQuestions.map((question) => { - if (!incomingMap[question.id]) { + const incomingEntry = incomingMap[question.id]; + if (!incomingEntry) { return question; } - return { - ...question, - ...incomingMap[question.id], - }; + return mergeQuestion(question, incomingEntry.question, incomingEntry.raw); }); - incomingQuestions.forEach((question) => { + incomingQuestions.forEach((question, index) => { const exists = merged.some((entry) => entry.id === question.id); if (!exists) { - merged.push(question); + merged.push(mergeQuestion(null, question, incomingRawQuestions[index] || {})); } }); - return merged.sort((a, b) => a.order_priority - b.order_priority); + return sortByOrderPriority(merged); +}; + +const mergeByNumericId = (existingItems, incomingItems) => { + if (!Array.isArray(existingItems) || existingItems.length === 0) { + return Array.isArray(incomingItems) ? [...incomingItems] : []; + } + + if (!Array.isArray(incomingItems) || incomingItems.length === 0) { + return [...existingItems]; + } + + const merged = [...existingItems]; + incomingItems.forEach((incomingItem) => { + const incomingId = parseInt(incomingItem?.id ?? 0); + if (!incomingId) { + merged.push(incomingItem); + return; + } + + const existingIndex = merged.findIndex((item) => parseInt(item?.id ?? 0) === incomingId); + if (existingIndex === -1) { + merged.push(incomingItem); + return; + } + + merged[existingIndex] = { + ...merged[existingIndex], + ...incomingItem, + }; + }); + + return merged; }; export function useSelfServeLogic() { @@ -170,24 +235,32 @@ export function useSelfServeLogic() { vehicle.value = previewData?.vehicle || null; session.value = previewData?.session || session.value; - const normalizedQuestions = Array.isArray(previewData?.questions) - ? previewData.questions.map(normalizeQuestion).sort((a, b) => a.order_priority - b.order_priority) + const previewQuestionsRaw = Array.isArray(previewData?.questions) ? previewData.questions : []; + const normalizedQuestions = previewQuestionsRaw.length > 0 + ? previewQuestionsRaw.map(normalizeQuestion) : []; const nextQuestions = mergeQuestions - ? mergeQuestionSets(questions.value, normalizedQuestions) + ? mergeQuestionSets(questions.value, normalizedQuestions, previewQuestionsRaw) : normalizedQuestions; questions.value = nextQuestions; answers.value = buildAnswersMap(nextQuestions); - conditions.value = Array.isArray(previewData?.conditions) + const normalizedConditions = Array.isArray(previewData?.conditions) ? previewData.conditions.map(normalizeCondition) : []; - rules.value = Array.isArray(previewData?.rules) + const normalizedRules = Array.isArray(previewData?.rules) ? previewData.rules.map(normalizeRule) : []; + conditions.value = mergeQuestions + ? mergeByNumericId(conditions.value, normalizedConditions) + : normalizedConditions; + rules.value = mergeQuestions + ? mergeByNumericId(rules.value, normalizedRules) + : normalizedRules; + const normalizedTasks = Array.isArray(previewData?.tasks) ? previewData.tasks.map(normalizeTask).sort((a, b) => a.order_priority - b.order_priority) : []; @@ -211,7 +284,8 @@ export function useSelfServeLogic() { return accumulator; }, {}); - const normalizedQuestions = summaryData.questions.map((question) => { + const summaryQuestionsRaw = summaryData.questions; + const normalizedQuestions = summaryQuestionsRaw.map((question) => { const normalized = normalizeQuestion(question); return { ...previewQuestionMap[normalized.id], @@ -219,17 +293,17 @@ export function useSelfServeLogic() { }; }); - const nextQuestions = mergeQuestionSets(questions.value, normalizedQuestions); + const nextQuestions = mergeQuestionSets(questions.value, normalizedQuestions, summaryQuestionsRaw); questions.value = nextQuestions; answers.value = buildAnswersMap(nextQuestions); } if (Array.isArray(summaryData?.conditions)) { - conditions.value = summaryData.conditions.map(normalizeCondition); + conditions.value = mergeByNumericId(conditions.value, summaryData.conditions.map(normalizeCondition)); } if (Array.isArray(summaryData?.rules)) { - rules.value = summaryData.rules.map(normalizeRule); + rules.value = mergeByNumericId(rules.value, summaryData.rules.map(normalizeRule)); } if (Array.isArray(summaryData?.tasks)) { @@ -349,6 +423,10 @@ export function useSelfServeLogic() { } await fetchSelfServeData(departmentId, null, laneId, normalizedReg); + answers.value = { + ...answers.value, + [parseInt(questionId)]: value, + }; return payload; } catch (error) { console.error("Error synchronizing vehicle answer:", error); @@ -381,6 +459,11 @@ export function useSelfServeLogic() { if (!question.condition_id || parseInt(question.condition_id) === 0) { return true; } + const hasRulesForCondition = rules.value.some((rule) => parseInt(rule.condition_id) === parseInt(question.condition_id)); + if (!hasRulesForCondition) { + // When backend payloads are partial, trust that included questions are visible. + return true; + } return evaluateCondition(question.condition_id); }) .sort((a, b) => a.order_priority - b.order_priority); @@ -392,6 +475,10 @@ export function useSelfServeLogic() { if (!task.condition_id || parseInt(task.condition_id) === 0) { return true; } + const hasRulesForCondition = rules.value.some((rule) => parseInt(rule.condition_id) === parseInt(task.condition_id)); + if (!hasRulesForCondition) { + return true; + } return evaluateCondition(task.condition_id); }) .sort((a, b) => a.order_priority - b.order_priority); diff --git a/src/i18n/locales/da.json b/src/i18n/locales/da.json index eec6d3e9..1bbe885a 100644 --- a/src/i18n/locales/da.json +++ b/src/i18n/locales/da.json @@ -2443,6 +2443,7 @@ "pickup": "Afhentning", "previous": "Forrige side", "reload": "Genindlæs", + "download_excel": "Download Excel", "show_only_pending": "Vis kun afventende", "show_only_today": "Vis kun i dag", "show_only_todays_pending": "Vis kun dagens afventende", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index b84c2ed1..e0c68335 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2443,6 +2443,7 @@ "pickup": "Abholung", "previous": "Vorherige Seite", "reload": "Neu laden", + "download_excel": "Excel herunterladen", "show_only_pending": "Nur ausstehende anzeigen", "show_only_today": "Nur heute anzeigen", "show_only_todays_pending": "Nur heutige ausstehende anzeigen", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 586e1ab7..0de10a4b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2443,6 +2443,7 @@ "pickup": "Pickup", "previous": "Previous page", "reload": "Reload", + "download_excel": "Download Excel", "show_only_pending": "Show only pending", "show_only_today": "Show only today", "show_only_todays_pending": "Show only today's pending", diff --git a/src/i18n/locales/no.json b/src/i18n/locales/no.json index 3528fe5b..637b9907 100644 --- a/src/i18n/locales/no.json +++ b/src/i18n/locales/no.json @@ -2438,6 +2438,7 @@ "pickup": "Henting", "previous": "Forrige side", "reload": "Last inn på nytt", + "download_excel": "Last ned Excel", "show_only_pending": "Vis bare venter", "show_only_today": "Vises kun i dag", "show_only_todays_pending": "Vis kun dagens ventende", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 119d54c6..df8b17ee 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -2438,6 +2438,7 @@ "pickup": "Pickup", "previous": "Föregående sida", "reload": "Reload", + "download_excel": "Ladda ner Excel", "show_only_pending": "Visa endast väntande", "show_only_today": "Visa endast i dag", "show_only_todays_pending": "Visa endast dagens väntande", diff --git a/src/main.js b/src/main.js index ebc6c79a..e1318c5d 100644 --- a/src/main.js +++ b/src/main.js @@ -13,6 +13,7 @@ import App from "@/App.vue"; import Buefy from 'buefy' import 'buefy/dist/css/buefy.css' import i18n from '@/i18n' +import { initializeAutoTableExports } from '@/services/AutoTableExportService.js'; import { API_URL, IS_DEV, POS_STEP_1_VERSION } from './config'; // export { API_URL, IS_DEV, POS_STEP_1_VERSION }; @@ -38,4 +39,6 @@ createApp(App) .provide('Colors', Colors) .provide('IS_DEV', IS_DEV) .provide('API_URL', API_URL) - .mount('#app'); \ No newline at end of file + .mount('#app'); + +initializeAutoTableExports(); diff --git a/src/services/AutoTableExportService.js b/src/services/AutoTableExportService.js new file mode 100644 index 00000000..b87c245c --- /dev/null +++ b/src/services/AutoTableExportService.js @@ -0,0 +1,159 @@ +import i18n from '@/i18n'; +import { exportRowsToExcel } from '@/services/TableExcelExportService.js'; + +const AUTO_EXPORT_TABLE_ATTR = 'data-auto-excel-export-attached'; +const AUTO_EXPORT_BUTTON_ATTR = 'data-auto-excel-export-button'; + +const isElementVisible = (element) => { + if (!element) { + return false; + } + + if (element.hidden) { + return false; + } + + if (typeof window === 'undefined' || typeof window.getComputedStyle !== 'function') { + return true; + } + + const style = window.getComputedStyle(element); + return style.display !== 'none' && style.visibility !== 'hidden'; +}; + +export const extractVisibleRowsFromTable = (tableElement) => { + if (!tableElement) { + return []; + } + + const headerCells = [...tableElement.querySelectorAll('thead th')]; + const headerLabels = headerCells.map((headerCell, index) => { + const label = String(headerCell.textContent || '').trim(); + return label || `Column ${index + 1}`; + }); + + const bodyRows = tableElement.querySelectorAll('tbody tr'); + const fallbackRows = tableElement.querySelectorAll('tr'); + const rows = (bodyRows.length ? [...bodyRows] : [...fallbackRows]) + .filter((row) => isElementVisible(row)); + + return rows + .map((row) => { + const cells = [...row.querySelectorAll('th, td')]; + if (!cells.length) { + return null; + } + + const rowObject = {}; + cells.forEach((cell, index) => { + const key = headerLabels[index] || `Column ${index + 1}`; + rowObject[key] = String(cell.textContent || '').trim(); + }); + return rowObject; + }) + .filter((row) => row && Object.keys(row).length > 0); +}; + +const buildDomExportFilename = (tableElement, tableIndex) => { + const currentPathname = typeof window !== 'undefined' && window.location + ? window.location.pathname + : 'table'; + const pathSegment = String(currentPathname || 'table') + .replace(/^\//, '') + .replace(/[\\/]+/g, '-') + .replace(/[^a-zA-Z0-9-_]+/g, '-') + .toLowerCase() || 'table'; + const tableLabel = tableElement.getAttribute('data-export-name') + || tableElement.id + || `table-${tableIndex + 1}`; + const safeLabel = String(tableLabel).replace(/[^a-zA-Z0-9-_]+/g, '-').toLowerCase(); + return `${pathSegment}-${safeLabel}-${new Date().toISOString().slice(0, 10)}.xlsx`; +}; + +const createAutoExportButton = (tableElement, tableIndex) => { + const wrapper = document.createElement('div'); + wrapper.className = 'mb-2 has-text-right'; + wrapper.setAttribute(AUTO_EXPORT_BUTTON_ATTR, '1'); + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'button is-dark is-small'; + button.textContent = i18n.global.t('pagination.download_excel'); + button.addEventListener('click', () => { + const rows = extractVisibleRowsFromTable(tableElement); + exportRowsToExcel(rows, { + filename: buildDomExportFilename(tableElement, tableIndex), + sheetName: tableElement.getAttribute('data-export-sheet') || 'Table', + }); + }); + + wrapper.appendChild(button); + return wrapper; +}; + +export const ensureAutoTableExportButtons = (root = document) => { + if (!root || typeof root.querySelectorAll !== 'function') { + return; + } + + const tables = [...root.querySelectorAll('table.table')].filter((table) => { + if (table.closest('.b-table')) { + return false; + } + if (table.closest('[data-disable-auto-excel-export="1"]')) { + return false; + } + return true; + }); + + tables.forEach((tableElement, index) => { + if (tableElement.hasAttribute(AUTO_EXPORT_TABLE_ATTR)) { + return; + } + + const button = createAutoExportButton(tableElement, index); + tableElement.parentNode?.insertBefore(button, tableElement); + tableElement.setAttribute(AUTO_EXPORT_TABLE_ATTR, '1'); + }); +}; + +export const initializeAutoTableExports = () => { + if (typeof window === 'undefined' || typeof document === 'undefined' || !document.body) { + return () => {}; + } + if (typeof MutationObserver === 'undefined') { + ensureAutoTableExportButtons(document); + return () => {}; + } + + let scheduled = false; + const run = () => { + scheduled = false; + ensureAutoTableExportButtons(document); + }; + const schedule = () => { + if (scheduled) { + return; + } + scheduled = true; + window.requestAnimationFrame(run); + }; + + schedule(); + const observer = new MutationObserver(schedule); + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + window.addEventListener('load', schedule); + window.addEventListener('popstate', schedule); + window.addEventListener('hashchange', schedule); + + return () => { + observer.disconnect(); + window.removeEventListener('load', schedule); + window.removeEventListener('popstate', schedule); + window.removeEventListener('hashchange', schedule); + }; +}; diff --git a/src/views/dashboards/superUserDashboard/vehicle/Vehicle.vue b/src/views/dashboards/superUserDashboard/vehicle/Vehicle.vue index 684fe5aa..89bd3097 100644 --- a/src/views/dashboards/superUserDashboard/vehicle/Vehicle.vue +++ b/src/views/dashboards/superUserDashboard/vehicle/Vehicle.vue @@ -6,6 +6,7 @@ import RestrictedPageWrapper from '@/components/page/wrappers/RestrictedPageWrap import SuperUserDashboardNavigation from '@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue'; import PageTitle from '@/components/global/PageTitle.vue'; import VehicleAnalyticsChart from '@/views/dashboards/superUserDashboard/vehicle/displays/VehicleAnalyticsChart.vue'; +import { exportRowsToExcel } from '@/services/TableExcelExportService.js'; import { aggregateDepartmentVisits, aggregateOrderFrequency, @@ -16,6 +17,7 @@ import { clampPerPage, fromDatetimeLocalValue, normalizeListResponse, + normalizePaginationMeta, normalizeRegistrationNumber, normalizeRelatedOrders, sanitizePositiveInt, @@ -34,6 +36,14 @@ const loading = reactive({ customers: false, search: false, }); +const exportLoading = reactive({ + washes: false, + customers: false, + subscriptionUsers: false, + vehicles: false, + xlvaskVehicles: false, + search: false, +}); const errors = reactive({ washes: '', @@ -239,6 +249,137 @@ const openOrder = async (orderId) => { if (departmentId) window.open(`/admin/${departmentId}/modules/pos/orders/${orderId}`, '_blank'); }; +const buildExportFilename = (suffix) => { + const reg = String(registrationNumber.value || 'vehicle').replace(/[^a-zA-Z0-9-_]+/g, '-').toLowerCase(); + return `${reg}-${suffix}-${new Date().toISOString().slice(0, 10)}.xlsx`; +}; + +const fetchAllPaginatedRows = async ({ perPage, requestPage }) => { + const rows = []; + const safePerPage = clampPerPage(perPage, 100); + let page = 1; + let totalPages = 1; + + do { + const response = await requestPage(page, safePerPage); + rows.push(...normalizeListResponse(response)); + const pagination = normalizePaginationMeta(response, safePerPage); + totalPages = pagination.totalPages; + page += 1; + } while (page <= totalPages); + + return rows; +}; + +const exportWashesToExcel = async () => { + exportLoading.washes = true; + try { + const allWashes = await fetchAllPaginatedRows({ + perPage: filters.washes.perPage, + requestPage: (page, perPage) => SessionUser.request('/modules/xlvask/usageLog', 'GET', buildUsageLogQuery({ + dateFrom: fromDatetimeLocalValue(filters.washes.dateFrom), + regNr: registrationNumber.value, + customerId: filters.washes.customerId || null, + vehicleId: filters.washes.vehicleId || null, + page, + perPage, + })), + }); + + const washIds = allWashes.map((entry) => entry?.WashId).filter(Boolean); + const allRelatedOrders = washIds.length + ? normalizeRelatedOrders(await SessionUser.superUser.modules.xlvask.functions.getRelatedOrders(washIds)) + : {}; + + const exportRows = allWashes.map((entry) => ({ + ...entry, + relatedOrderIds: Array.isArray(allRelatedOrders?.[entry?.WashId]) ? allRelatedOrders[entry.WashId].join(', ') : '', + })); + + exportRowsToExcel(exportRows, { + filename: buildExportFilename('washes'), + sheetName: 'Washes', + }); + } catch (error) { + errors.washes = parseError(error); + } finally { + exportLoading.washes = false; + } +}; + +const exportCustomersToExcel = () => { + exportLoading.customers = true; + try { + exportRowsToExcel(customers.value, { + filename: buildExportFilename('customers'), + sheetName: 'Customers', + }); + } finally { + exportLoading.customers = false; + } +}; + +const exportSubscriptionUsersToExcel = () => { + exportLoading.subscriptionUsers = true; + try { + exportRowsToExcel(relatedSubscriptionUsers.value, { + filename: buildExportFilename('subscription-users'), + sheetName: 'SubscriptionUsers', + }); + } finally { + exportLoading.subscriptionUsers = false; + } +}; + +const exportVehiclesToExcel = async () => { + exportLoading.vehicles = true; + try { + const allVehicles = await fetchAllPaginatedRows({ + perPage: filters.details.perPage, + requestPage: (page, perPage) => SessionUser.request('/vehicles', 'GET', buildVehiclesQuery({ + id: filters.details.id || null, + reg: registrationNumber.value, + customerId: filters.details.customerId || null, + page, + perPage, + })), + }); + + exportRowsToExcel(allVehicles, { + filename: buildExportFilename('vehicle-details'), + sheetName: 'Vehicles', + }); + } catch (error) { + errors.details = parseError(error); + } finally { + exportLoading.vehicles = false; + } +}; + +const exportXlvaskVehiclesToExcel = () => { + exportLoading.xlvaskVehicles = true; + try { + exportRowsToExcel(xlvaskVehicles.value, { + filename: buildExportFilename('xlvask-vehicles'), + sheetName: 'XLVaskVehicles', + }); + } finally { + exportLoading.xlvaskVehicles = false; + } +}; + +const exportSearchResultsToExcel = () => { + exportLoading.search = true; + try { + exportRowsToExcel(searchResults.value, { + filename: buildExportFilename('search'), + sheetName: 'SearchResults', + }); + } finally { + exportLoading.search = false; + } +}; + onMounted(async () => { filters.search.term = registrationNumber.value; await refreshAll(); @@ -283,6 +424,9 @@ watch(registrationNumber, async (next, prev) => {