From 08a4dd0792e71fd9727e0d1cdc9d2878813f0a6e Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Mon, 18 Nov 2024 09:39:36 -0300 Subject: [PATCH 01/31] Update table, actions and utils to work with new table pagination --- src/components/table.js | 56 ++++++++++++++++++++++------ src/store/modules/content/actions.js | 8 +++- src/utils.js | 2 +- 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/components/table.js b/src/components/table.js index b8c4db9..eff2567 100644 --- a/src/components/table.js +++ b/src/components/table.js @@ -19,10 +19,26 @@ export const table = { const store = useStore(); const rows = ref([]); const columns = ref([]); + const page = ref(1); + const pageCount = ref(0); + const pageTotalItems = ref(10); const loading = computed(computedVar({ store, mutation: "content/UPDATE_LOADING", field: "loading" })); + const sorter = ref(null); + + const pagination = computed(() => ({ + page: page.value, + pageCount: pageCount.value, + pageSize: 10, + pageSlot: 7, + pageTotalItems: pageTotalItems.value, + } + )); const setTableData = async () => { - const currentResult = await store.dispatch("content/requestData", { detail: true }); + const currentResult = await store.dispatch("content/requestData", { detail: true, page: page.value, sorter: sorter.value }); + pageCount.value = currentResult.metadata.pages.total_pages; + pageTotalItems.value = currentResult.metadata.pages.total_records; + if (!currentResult || !currentResult.data ) { rows.value = []; return; @@ -40,18 +56,16 @@ export const table = { columnValue.minWidth = "160px"; columnValue.title = currentResult.metadata.type; rows.value = tableData.rows; - - const arraySortColumns = currentResult.metadata.type == "Meta atingida" ? [4, 5] : [3, 4, 5]; - arraySortColumns.forEach(col => columns.value[col].sorter = sortNumericValue(columns.value[col])); } - const sortNumericValue = (column) => (a, b) => - parseFloat(a[column.key].replace(/[%,.]/g, "")) - parseFloat(b[column.key].replace(/[%,.]/g, "")); - - onMounted(async () => { + const updateTableContent = async () => { loading.value = true; await setTableData(); loading.value = false; + } + + onMounted(async () => { + updateTableContent() }); watch( @@ -62,17 +76,32 @@ export const table = { async () => { // Avoid render before change tab if (Array.isArray(store.state.content.form.sickImmunizer)) { - loading.value = true; - await setTableData(); - loading.value = false; + page.value = 1 + updateTableContent() } } ); + const handlePageChange = async (newPage) => { + page.value = newPage + updateTableContent() + } + + const handleSorterChange = async (newSorter) => { // { columnKey: string; order: string } + sorter.value = newSorter; + if (!newSorter.order) { + sorter.value = null; + } + updateTableContent(); + } + return { columns, loading, rows, + pagination, + handlePageChange, + handleSorterChange, formPopulated: computed(() => store.getters["content/selectsPopulated"]) }; }, @@ -85,8 +114,11 @@ export const table = { :columns="columns" :data="rows" :bordered="false" - :pagination="{ pageSlot:7 }" + :pagination="pagination" + :remote="true" :scrollbar-props="{ trigger: 'none', xScrollable: true }" + @update:page="handlePageChange" + @update:sorter="handleSorterChange" />
{ { title, key: column, - sorter: 'default', + sorter: ["código"].includes(column) ? false : "default", width, titleAlign: "left", align, From 90b217529b3a6dc700e9971c878fb6e798bb68be Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Wed, 4 Dec 2024 15:46:15 -0300 Subject: [PATCH 02/31] Wait filters and blocker data before load selected values from url --- src/components/main-card.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/main-card.js b/src/components/main-card.js index 50d9e04..2b9665b 100644 --- a/src/components/main-card.js +++ b/src/components/main-card.js @@ -1,5 +1,5 @@ import { NCard, NSkeleton, useMessage, NModal, NButton, NSpin } from "naive-ui"; -import { ref, computed, onBeforeMount, watch } from "vue/dist/vue.esm-bundler"; +import { ref, computed, onMounted, watch } from "vue/dist/vue.esm-bundler"; import { chart as Chart } from "./chart"; import { map as Map } from "./map/map"; import { table as Table } from "./table"; @@ -197,7 +197,7 @@ export const mainCard = { } ) - onBeforeMount(async () => { + onMounted(async () => { getWindowWidth(); await store.dispatch("content/updateFormSelect"); setStateFromUrl(); From da612137dc929be600b60c514b9baca79f915681 Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Wed, 4 Dec 2024 15:51:55 -0300 Subject: [PATCH 03/31] Update pagination to avoid user jumping pages out of server cache --- src/assets/css/components/table.css | 15 ++++++++++----- src/components/table.js | 3 +++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/assets/css/components/table.css b/src/assets/css/components/table.css index 0f09748..90f66a6 100644 --- a/src/assets/css/components/table.css +++ b/src/assets/css/components/table.css @@ -48,11 +48,6 @@ font-weight: 500; } -.table-custom .n-data-table__pagination { - justify-content: flex-start; -} - - /* MediaQuery */ @media (max-width: 800px) { @@ -86,3 +81,13 @@ border-bottom: 1px solid #d1d1d1; border-top: 1px solid #d1d1d1; } + +/* Custom pagination */ + +.n-input.n-input--resizable.n-input--stateful { + pointer-events: none; +} + +.n-pagination.n-pagination--simple .n-input.n-input--resizable.n-input--stateful { + --n-border: none !important; +} diff --git a/src/components/table.js b/src/components/table.js index eff2567..f8bd592 100644 --- a/src/components/table.js +++ b/src/components/table.js @@ -31,6 +31,9 @@ export const table = { pageSize: 10, pageSlot: 7, pageTotalItems: pageTotalItems.value, + simple: true, + prev: () => "🠐 anterior", + next: () => "seguinte 🠒", } )); From 2bdfc52809fe88140b5cb0defa9058859c6f2839 Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Wed, 4 Dec 2024 15:52:18 -0300 Subject: [PATCH 04/31] Fix use data before check if it exists --- src/components/table.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/table.js b/src/components/table.js index f8bd592..e2040d9 100644 --- a/src/components/table.js +++ b/src/components/table.js @@ -39,14 +39,15 @@ export const table = { const setTableData = async () => { const currentResult = await store.dispatch("content/requestData", { detail: true, page: page.value, sorter: sorter.value }); - pageCount.value = currentResult.metadata.pages.total_pages; - pageTotalItems.value = currentResult.metadata.pages.total_records; if (!currentResult || !currentResult.data ) { rows.value = []; return; } + pageCount.value = Number(currentResult.metadata.pages.total_pages); + pageTotalItems.value = currentResult.metadata.pages.total_records; + const tableData = formatToTable(currentResult.data, currentResult.localNames, currentResult.metadata); columns.value = tableData.header; const dosesQtd = columns.value.findIndex(column => column.title === 'Doses (qtd)'); From 2211dd45fe7888134d9f7850f5f4fdcbb543dad8 Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Wed, 4 Dec 2024 15:53:00 -0300 Subject: [PATCH 05/31] Avoid download interface current data if table tab selected --- src/components/sub-buttons.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/sub-buttons.js b/src/components/sub-buttons.js index ae3391c..184bc89 100644 --- a/src/components/sub-buttons.js +++ b/src/components/sub-buttons.js @@ -410,7 +410,7 @@ export const subButtons = {
Dados
-
+
From 6c68396b636f9b576db5c92269c26e905c940bfc Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Thu, 2 Jan 2025 19:29:04 -0300 Subject: [PATCH 06/31] Updates to limit download --- src/components/sub-buttons.js | 44 ++++++++++++++++---- src/store/modules/content/actions.js | 10 +++-- src/store/modules/content/getDefaultState.js | 4 +- src/store/modules/content/mutations.js | 6 +++ src/utils.js | 5 +-- 5 files changed, 55 insertions(+), 14 deletions(-) diff --git a/src/components/sub-buttons.js b/src/components/sub-buttons.js index 184bc89..5d0b31d 100644 --- a/src/components/sub-buttons.js +++ b/src/components/sub-buttons.js @@ -1,5 +1,5 @@ import { ref, computed } from "vue/dist/vue.esm-bundler"; -import { NButton, NIcon, NCard, NScrollbar, NTabs, NTabPane } from "naive-ui"; +import { NButton, NIcon, NCard, NScrollbar, NTabs, NTabPane, NSpin } from "naive-ui"; import { biBook, biListUl, biDownload, biShareFill, biFiletypeCsv, biGraphUp } from "../icons.js"; import { formatToTable, formatDatePtBr } from "../utils.js"; import { useStore } from "vuex"; @@ -37,6 +37,10 @@ export const subButtons = { const showModalVac = ref(false); const legend = ref(computed(() => store.state.content.legend)); const csvAllDataLink = ref(computed(() => store.state.content.csvAllDataLink)); + const csvRowsExceeded = ref(computed(() => store.state.content.csvRowsExceeded)); + const maxCsvExportRows = ref(computed(() => store.state.content.maxCsvExportRows)); + const loadingDownload = ref(false) + const formPopulated = computed(() => store.getters["content/selectsPopulated"]) const aboutVaccines = computed(() => { const text = store.state.content.aboutVaccines; @@ -62,6 +66,11 @@ export const subButtons = { }) const downloadSvg = () => { + if (!formPopulated.value) { + store.commit('message/ERROR', "Preencha os seletores para gerar mapa"); + return; + } + // GA Event if (window.gtag) { window.gtag('event', 'file_download', { @@ -91,6 +100,12 @@ export const subButtons = { 'file_name': 'mapa.png' }); } + + if (!formPopulated.value) { + store.commit('message/ERROR', "Preencha os seletores para gerar mapa"); + return; + } + const svgElement = document.querySelector("#canvas>svg"); const svgContent = new XMLSerializer().serializeToString(svgElement); @@ -135,6 +150,7 @@ export const subButtons = { } const downloadCsv = async () => { + loadingDownload.value = true; const periodStart = store.state.content.form.periodStart; const periodEnd = store.state.content.form.periodEnd; let years = []; @@ -145,10 +161,11 @@ export const subButtons = { } } - const currentResult = await store.dispatch("content/requestData", { detail: true }); + const currentResult = await store.dispatch("content/requestData", { detail: true, csv: true }); if (!currentResult) { store.commit('message/ERROR', "Preencha os seletores para gerar csv"); + loadingDownload.value = false; return; } // GA Event @@ -163,15 +180,17 @@ export const subButtons = { const tableData = formatToTable(currentResult.data, currentResult.localNames, currentResult.metadata); const header = tableData.header.map(x => Object.values(x)[0]) - header[header.findIndex(head => head === "Valor")] = currentResult.metadata.type + const type = store.state.content.form.type + header[header.findIndex(head => head === "Valor")] = type const rows = tableData.rows.map(x => Object.values(x)) - if (currentResult.metadata.type == "Doses aplicadas") { + if (type == "Doses aplicadas") { const index = header.findIndex(column => column === 'Doses (qtd)') header.splice(index, 1) rows.forEach(row => row.splice(index, 1)) } const csvwriter = new CsvWriterGen(header, rows); csvwriter.anchorElement('tabela'); + loadingDownload.value = false; } const openInNewTab = () => { @@ -282,7 +301,10 @@ export const subButtons = { showModalVac, clickShowVac, modalGlossary, - formatDatePtBr + formatDatePtBr, + csvRowsExceeded, + maxCsvExportRows, + loadingDownload }; }, template: ` @@ -343,6 +365,7 @@ export const subButtons = {
Faça o download de conteúdos
@@ -410,7 +433,7 @@ export const subButtons = {
Dados
-
+
@@ -420,7 +443,14 @@ export const subButtons = {

Os dados que estão sendo utilizados nesta interface

- +   Baixar diff --git a/src/store/modules/content/actions.js b/src/store/modules/content/actions.js index 8a832c9..e58c48f 100644 --- a/src/store/modules/content/actions.js +++ b/src/store/modules/content/actions.js @@ -36,7 +36,8 @@ export default { stateNameAsCode = true, stateTotal = false, page = null, - sorter = null + sorter = null, + csv = false } = {} ) { const api = new DataFetcher(state.apiUrl); @@ -100,7 +101,7 @@ export default { } const [result, localNames] = await Promise.all([ - api.request(`data/${request}`), + api.request((csv ? `export-csv/` : `data/`) + request), api.request(isStateData) ]); @@ -113,14 +114,17 @@ export default { return { result: {}, localNames: {} } } else if (!result || result.data && result.data.length <= 1) { commit("UPDATE_TITLES", null); + this.commit( "message/WARNING", "Não há dados disponíveis para os parâmetros selecionados.", { root: true } ); return { result: {}, localNames: {} } - } else { + } else if (result.metadata) { commit("UPDATE_TITLES", result.metadata.titles); + commit("UPDATE_CSV_ROWS_EXCEED", result.metadata.csv_rows_exceeded); + commit("UPDATE_CSV_MAX_EXPORT_ROWS", result.metadata.max_csv_export_rows); } if (form.type !== "Doses aplicadas") { diff --git a/src/store/modules/content/getDefaultState.js b/src/store/modules/content/getDefaultState.js index 0c548c7..f4239b6 100644 --- a/src/store/modules/content/getDefaultState.js +++ b/src/store/modules/content/getDefaultState.js @@ -37,6 +37,8 @@ export const getDefaultState = () => { granularityBlocks: null, disableMap: false, disableChart: false, - loading: false + loading: false, + maxCsvExportRows: 10000, + csvRowsExceeded: false } } diff --git a/src/store/modules/content/mutations.js b/src/store/modules/content/mutations.js index 1afe351..3d1f996 100644 --- a/src/store/modules/content/mutations.js +++ b/src/store/modules/content/mutations.js @@ -123,6 +123,12 @@ export default { UPDATE_LAST_UPDATE_DATE(state, payload) { state.lastUpdateDate = payload; }, + UPDATE_CSV_ROWS_EXCEED(state, payload) { + state.csvRowsExceeded = payload; + }, + UPDATE_CSV_MAX_EXPORT_ROWS(state, payload) { + state.maxCsvExportRows = payload; + }, UPDATE_ACRONYMS(state, payload) { const result = []; const acronymsHeader = payload[0]; diff --git a/src/utils.js b/src/utils.js index dda8498..7cf5c94 100644 --- a/src/utils.js +++ b/src/utils.js @@ -61,8 +61,7 @@ export const formatToTable = (data, localNames, metadata) => { let title = column.charAt(0).toUpperCase() + column.slice(1); if (title === "Doenca") { title = "Doença"; - } - if (title === "Doses") { + } else if (title === "Doses") { title = "Doses (qtd)"; } header.push( @@ -109,7 +108,7 @@ export const formatToTable = (data, localNames, metadata) => { } else if (["população", "doses"].includes(key)) { row[header[j].key] = value.toLocaleString("pt-BR"); continue - } else if (metadata.type == "Meta atingida" && key == "valor") { + } else if (metadata && metadata.type == "Meta atingida" && key == "valor") { row[header[j].key] = parseInt(value) === 1 ? "Sim" : "Não"; continue } From c659b78a4ba3b797a553ba7f1d875065cf1bbc59 Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Thu, 2 Jan 2025 19:29:42 -0300 Subject: [PATCH 07/31] Update imports package json --- package-lock.json | 51 +++++++++++++++++++++++++++++++++++------------ package.json | 6 ++---- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index c9a5f20..74998e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "chart.js": "^4.2.1", "chartjs-plugin-datalabels": "^2.2.0", "cors": "^2.8.5", + "csvwritergen": "^0.0.4", "dotenv": "^16.0.3", "express": "^4.18.2", "naive-ui": "^2.34.4", @@ -30,6 +31,7 @@ "version": "7.21.4", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.4.tgz", "integrity": "sha512-alVJj7k7zIxqBZ7BTRhz0IqJFxW1VJbm6N8JbcYhQ186df9ZBPbZBmWSqAMXwHGsCJdYks7z/voa3ibiS5bCIw==", + "dev": true, "bin": { "parser": "bin/babel-parser.js" }, @@ -462,6 +464,7 @@ "version": "3.2.47", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.47.tgz", "integrity": "sha512-p4D7FDnQb7+YJmO2iPEv0SQNeNzcbHdGByJDsT4lynf63AFkOTFN07HsiRSvjGo0QrxR/o3d0hUyNCUnBU2Tig==", + "dev": true, "dependencies": { "@babel/parser": "^7.16.4", "@vue/shared": "3.2.47", @@ -473,6 +476,7 @@ "version": "3.2.47", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.47.tgz", "integrity": "sha512-dBBnEHEPoftUiS03a4ggEig74J2YBZ2UIeyfpcRM2tavgMWo4bsEfgCGsu+uJIL/vax9S+JztH8NmQerUo7shQ==", + "dev": true, "dependencies": { "@vue/compiler-core": "3.2.47", "@vue/shared": "3.2.47" @@ -482,6 +486,7 @@ "version": "3.2.47", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.47.tgz", "integrity": "sha512-rog05W+2IFfxjMcFw10tM9+f7i/+FFpZJJ5XHX72NP9eC2uRD+42M3pYcQqDXVYoj74kHMSEdQ/WmCjt8JFksQ==", + "dev": true, "dependencies": { "@babel/parser": "^7.16.4", "@vue/compiler-core": "3.2.47", @@ -499,20 +504,23 @@ "version": "3.2.47", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.47.tgz", "integrity": "sha512-wVXC+gszhulcMD8wpxMsqSOpvDZ6xKXSVWkf50Guf/S+28hTAXPDYRTbLQ3EDkOP5Xz/+SY37YiwDquKbJOgZw==", + "dev": true, "dependencies": { "@vue/compiler-dom": "3.2.47", "@vue/shared": "3.2.47" } }, "node_modules/@vue/devtools-api": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.5.0.tgz", - "integrity": "sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q==" + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "dev": true }, "node_modules/@vue/reactivity": { "version": "3.2.47", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.47.tgz", "integrity": "sha512-7khqQ/75oyyg+N/e+iwV6lpy1f5wq759NdlS1fpAhFXa8VeAIKGgk2E/C4VF59lx5b+Ezs5fpp/5WsRYXQiKxQ==", + "dev": true, "dependencies": { "@vue/shared": "3.2.47" } @@ -521,6 +529,7 @@ "version": "3.2.47", "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.47.tgz", "integrity": "sha512-m8lGXw8rdnPVVIdIFhf0LeQ/ixyHkH5plYuS83yop5n7ggVJU+z5v0zecwEnX7fa7HNLBhh2qngJJkxpwEEmYA==", + "dev": true, "dependencies": { "@babel/parser": "^7.16.4", "@vue/compiler-core": "3.2.47", @@ -533,6 +542,7 @@ "version": "3.2.47", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.2.47.tgz", "integrity": "sha512-RZxbLQIRB/K0ev0K9FXhNbBzT32H9iRtYbaXb0ZIz2usLms/D55dJR2t6cIEUn6vyhS3ALNvNthI+Q95C+NOpA==", + "dev": true, "dependencies": { "@vue/reactivity": "3.2.47", "@vue/shared": "3.2.47" @@ -542,6 +552,7 @@ "version": "3.2.47", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.2.47.tgz", "integrity": "sha512-ArXrFTjS6TsDei4qwNvgrdmHtD930KgSKGhS5M+j8QxXrDJYLqYw4RRcDy1bz1m1wMmb6j+zGLifdVHtkXA7gA==", + "dev": true, "dependencies": { "@vue/runtime-core": "3.2.47", "@vue/shared": "3.2.47", @@ -551,12 +562,14 @@ "node_modules/@vue/runtime-dom/node_modules/csstype": { "version": "2.6.21", "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", - "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==" + "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==", + "dev": true }, "node_modules/@vue/server-renderer": { "version": "3.2.47", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.2.47.tgz", "integrity": "sha512-dN9gc1i8EvmP9RCzvneONXsKfBRgqFeFZLurmHOveL7oH6HiFXJw5OGu294n1nHc/HMgTy6LulU/tv5/A7f/LA==", + "dev": true, "dependencies": { "@vue/compiler-ssr": "3.2.47", "@vue/shared": "3.2.47" @@ -568,7 +581,8 @@ "node_modules/@vue/shared": { "version": "3.2.47", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.47.tgz", - "integrity": "sha512-BHGyyGN3Q97EZx0taMQ+OLNuZcW3d37ZEVmEAyeoA9ERdGvm9Irc/0Fua8SNyOtV1w6BS4q25wbMzJujO9HIfQ==" + "integrity": "sha512-BHGyyGN3Q97EZx0taMQ+OLNuZcW3d37ZEVmEAyeoA9ERdGvm9Irc/0Fua8SNyOtV1w6BS4q25wbMzJujO9HIfQ==", + "dev": true }, "node_modules/abbrev": { "version": "1.1.1", @@ -901,7 +915,8 @@ "node_modules/csvwritergen": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/csvwritergen/-/csvwritergen-0.0.4.tgz", - "integrity": "sha512-uRVvkCqn5pPOD1lU7jPRTk/+0uajX4F0Mb/AZ9UJq/l6Iq9Q3YgRlhLALyNp2C537YEihFNxOgK4ns1hpph8pQ==" + "integrity": "sha512-uRVvkCqn5pPOD1lU7jPRTk/+0uajX4F0Mb/AZ9UJq/l6Iq9Q3YgRlhLALyNp2C537YEihFNxOgK4ns1hpph8pQ==", + "dev": true }, "node_modules/date-fns": { "version": "2.30.0", @@ -1139,7 +1154,8 @@ "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true }, "node_modules/etag": { "version": "1.8.1", @@ -1824,6 +1840,7 @@ "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, "dependencies": { "sourcemap-codec": "^1.4.8" } @@ -1945,6 +1962,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "dev": true, "funding": [ { "type": "github", @@ -2192,7 +2210,8 @@ "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true }, "node_modules/picomatch": { "version": "2.3.1", @@ -2231,6 +2250,7 @@ "version": "8.4.21", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -2565,6 +2585,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -2573,6 +2594,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -2581,7 +2603,8 @@ "version": "1.4.8", "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "deprecated": "Please use @jridgewell/sourcemap-codec instead" + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true }, "node_modules/spdx-correct": { "version": "3.2.0", @@ -2920,6 +2943,7 @@ "version": "3.2.47", "resolved": "https://registry.npmjs.org/vue/-/vue-3.2.47.tgz", "integrity": "sha512-60188y/9Dc9WVrAZeUVSDxRQOZ+z+y5nO2ts9jWXSTkMvayiWxCWOWtBQoYjLeccfXkiiPZWAHcV+WTPhkqJHQ==", + "dev": true, "dependencies": { "@vue/compiler-dom": "3.2.47", "@vue/compiler-sfc": "3.2.47", @@ -2929,11 +2953,12 @@ } }, "node_modules/vue-router": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.2.4.tgz", - "integrity": "sha512-9PISkmaCO02OzPVOMq2w82ilty6+xJmQrarYZDkjZBfl4RvYAlt4PKnEX21oW4KTtWfa9OuO/b3qk1Od3AEdCQ==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.0.tgz", + "integrity": "sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w==", + "dev": true, "dependencies": { - "@vue/devtools-api": "^6.5.0" + "@vue/devtools-api": "^6.6.4" }, "funding": { "url": "https://github.com/sponsors/posva" diff --git a/package.json b/package.json index a5abb3d..012739b 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "chart.js": "^4.2.1", "chartjs-plugin-datalabels": "^2.2.0", "cors": "^2.8.5", + "csvwritergen": "^0.0.4", "dotenv": "^16.0.3", "express": "^4.18.2", "naive-ui": "^2.34.4", @@ -24,11 +25,8 @@ "npm-run-all": "^4.1.5", "vite": "^4.2.0", "vue": "^3.2.47", + "vue-router": "^4.5.0", "vuex": "^4.1.0", "vuex-map-fields": "^1.4.1" - }, - "dependencies": { - "csvwritergen": "^0.0.4", - "vue-router": "^4.2.4" } } From 77f3038cdfe24a0f868af84b2b7d3ce004915921 Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Mon, 3 Feb 2025 16:43:17 -0300 Subject: [PATCH 08/31] Fix remove sort local from this version --- src/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.js b/src/utils.js index 7cf5c94..a68642f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -68,7 +68,7 @@ export const formatToTable = (data, localNames, metadata) => { { title, key: column, - sorter: ["código"].includes(column) ? false : "default", + sorter: ["código", "local"].includes(column) ? false : "default", width, titleAlign: "left", align, From af46226142eda45a24235738f57fcab470470fca Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Tue, 9 Sep 2025 17:48:00 -0300 Subject: [PATCH 09/31] Stop loading if error in download --- src/components/sub-buttons.js | 4 ++++ src/store/modules/content/actions.js | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/sub-buttons.js b/src/components/sub-buttons.js index 5d0b31d..3b114d1 100644 --- a/src/components/sub-buttons.js +++ b/src/components/sub-buttons.js @@ -163,6 +163,10 @@ export const subButtons = { const currentResult = await store.dispatch("content/requestData", { detail: true, csv: true }); + if (currentResult && currentResult.error) { + loadingDownload.value = false; + } + if (!currentResult) { store.commit('message/ERROR', "Preencha os seletores para gerar csv"); loadingDownload.value = false; diff --git a/src/store/modules/content/actions.js b/src/store/modules/content/actions.js index e58c48f..3970cda 100644 --- a/src/store/modules/content/actions.js +++ b/src/store/modules/content/actions.js @@ -111,7 +111,7 @@ export default { "Não foi possível carregar os dados. Tente novamente mais tarde.", { root: true } ); - return { result: {}, localNames: {} } + return { result: {}, localNames: {}, error: result.error } } else if (!result || result.data && result.data.length <= 1) { commit("UPDATE_TITLES", null); From af142f11d7f1b68be9a12263b3c8ee6337804635 Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Wed, 10 Sep 2025 09:52:27 -0300 Subject: [PATCH 10/31] Enhance error notification --- src/store/modules/content/actions.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/store/modules/content/actions.js b/src/store/modules/content/actions.js index 3970cda..dcc7802 100644 --- a/src/store/modules/content/actions.js +++ b/src/store/modules/content/actions.js @@ -166,7 +166,15 @@ export default { [ mutation, endpoint ] ) { const api = new DataFetcher(state.apiUrl); - const payload = await api.request(endpoint); - commit(mutation, payload); + try { + const payload = await api.request(endpoint); + commit(mutation, payload); + } catch(e){ + this.commit( + "message/ERROR", + `Não foi possível carregar os dados de '/${endpoint}'`, + { root: true } + ); + } }, } From 6d7a412069bf27b60e6b66063e6c842111164bf1 Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Fri, 3 Oct 2025 16:31:10 -0300 Subject: [PATCH 11/31] Add AbortController to api data requests --- package.json | 2 +- src/components/chart.js | 4 ++++ src/components/map/map.js | 30 +++++++++++++++++++++++--- src/components/sub-buttons.js | 3 +++ src/components/table.js | 4 ++++ src/data-fetcher.js | 32 ++++++++++++++++++++++------ src/main.js | 2 +- src/store/modules/content/actions.js | 26 ++++++++++++++++++++-- 8 files changed, 89 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 012739b..c643f98 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dashboard", "private": true, - "version": "1.5.1", + "version": "1.5.2", "type": "module", "main": "dist/dashboard.js", "scripts": { diff --git a/src/components/chart.js b/src/components/chart.js index a393ce1..4c67523 100644 --- a/src/components/chart.js +++ b/src/components/chart.js @@ -303,6 +303,10 @@ export const chart = { stateTotal: true, }); + if (result && result.aborted) { + return; + } + if (!result || !result.data) { renderChart(); return {}; diff --git a/src/components/map/map.js b/src/components/map/map.js index f31a403..967856e 100644 --- a/src/components/map/map.js +++ b/src/components/map/map.js @@ -15,6 +15,7 @@ export const map = { const map = ref(null); const yearMapElement = ref(null); const mapChart = ref(null); + const timeOutId = ref(null); const store = useStore(); const loading = computed(computedVar({ store, mutation: "content/UPDATE_LOADING", field: "loading" })); const datasetStates = ref(null); @@ -95,6 +96,11 @@ export const map = { loading.value = true; const results = await store.dispatch("content/requestData"); + + if (results && results.aborted) { + return; + } + try { let mapSetup = { element: mapElement, @@ -137,10 +143,16 @@ export const map = { if (local+granularity !== currentLocal.value) { map.value = await queryMap(local); } + if (map.value.aborted) { + return; + } currentLocal.value = local + granularity; } else if (local+granularity !== currentLocal.value) { const mapElement = document.querySelector('#map'); map.value = await queryMap("BR"); + if (map.value.aborted) { + return; + } renderMap({ element: mapElement, map: map.value }); currentLocal.value = "BR" + granularity; } @@ -179,16 +191,28 @@ export const map = { } ) + watch( + () => store.state.content.tab, + async (tab) => { + if (tab & tab !== 'map') { + clearTimeout(timeOutId.value) + } + } + ) + onMounted(async () => { // Avoiding wrong map loading - setTimeout(async () => { + // TODO: Centralize tables contents requests to better requests code + timeOutId.value = setTimeout(async () => { + const tab = store.state.content.tab + const tabIsMap = !tab || tab === 'map'; // If map not setted by watcher const mapElement = document.querySelector('#map'); - if(mapElement && !mapElement.innerHTML) { + if (mapElement && !mapElement.innerHTML && tabIsMap) { await updateMap(store.state.content.form.local); await setMap(); } - }, 500); + }, 2000); }); return { diff --git a/src/components/sub-buttons.js b/src/components/sub-buttons.js index 3b114d1..b715b8d 100644 --- a/src/components/sub-buttons.js +++ b/src/components/sub-buttons.js @@ -163,6 +163,9 @@ export const subButtons = { const currentResult = await store.dispatch("content/requestData", { detail: true, csv: true }); + if (currentResult && currentResult.aborted) { + return; + } if (currentResult && currentResult.error) { loadingDownload.value = false; } diff --git a/src/components/table.js b/src/components/table.js index e2040d9..4b9c9b7 100644 --- a/src/components/table.js +++ b/src/components/table.js @@ -40,6 +40,10 @@ export const table = { const setTableData = async () => { const currentResult = await store.dispatch("content/requestData", { detail: true, page: page.value, sorter: sorter.value }); + if (currentResult && currentResult.aborted) { + return; + } + if (!currentResult || !currentResult.data ) { rows.value = []; return; diff --git a/src/data-fetcher.js b/src/data-fetcher.js index 997f0a8..fe60e90 100644 --- a/src/data-fetcher.js +++ b/src/data-fetcher.js @@ -3,15 +3,20 @@ export class DataFetcher { this.api = api; } - async requestData(endPoint, apiPoint = "/wp-json/api/v1/") { - const self = this; - + async requestData(endPoint, apiPoint = "/wp-json/api/v1/", signal) { try { - const response = await fetch(self.api + apiPoint + endPoint); + const args = [this.api + apiPoint + endPoint]; + if (signal) { + args.push({ signal }); + } + const response = await fetch(...args); const data = await response.json(); return data; } catch (error) { - return { error } + if (error.name === 'AbortError') { + return { aborted: true }; + } + return { error }; } } @@ -20,8 +25,21 @@ export class DataFetcher { return result; } - async requestSettingApiEndPoint(endPoint, apiEndpoint) { - const result = await this.requestData(endPoint, apiEndpoint); + async request(endPoint, signal) { + const args = [endPoint, "/wp-json/api/v1/"]; + if (signal) { + args.push(signal); + } + const result = await this.requestData(...args); + return result; + } + + async requestSettingApiEndPoint(endPoint, apiEndpoint, signal) { + const args = [endPoint, apiEndpoint]; + if (args) { + args.push(signal); + } + const result = await this.requestData(...args); return result; } } diff --git a/src/main.js b/src/main.js index a2ba8d2..940138b 100644 --- a/src/main.js +++ b/src/main.js @@ -53,7 +53,7 @@ export default class MCT { }, setup() { const store = useStore(); - const tab = computed(computedVar({ store, mutation: "content/UPDATE_TAB", field: "tab" })); + const tab = computed(computedVar({ store, mutation: "content/UPDATE_TAB", field: "tab" })); const tabBy = computed(computedVar({ store, mutation: "content/UPDATE_TABBY", field: "tabBy" })); const disableMap = computed(() => store.state.content.disableMap); const disableChart = computed(() => store.state.content.disableChart); diff --git a/src/store/modules/content/actions.js b/src/store/modules/content/actions.js index dcc7802..3616a6e 100644 --- a/src/store/modules/content/actions.js +++ b/src/store/modules/content/actions.js @@ -1,12 +1,23 @@ import { DataFetcher } from "../../../data-fetcher"; +let currentController; +let currentControllerMap; + export default { async requestMap( { state }, { map } = {} ) { const api = new DataFetcher(state.apiUrl); - const result = await api.request(`map/${map}`); + + if (currentControllerMap) { + currentControllerMap.abort(); + } + + currentControllerMap = new AbortController(); + const signal = currentControllerMap.signal; + + const result = await api.request(`map/${map}`, signal); return result; }, async updateExtraFilterButton({ commit }, [ title, slug ]) { @@ -40,9 +51,16 @@ export default { csv = false } = {} ) { + if (currentController) { + currentController.abort(); + } + const api = new DataFetcher(state.apiUrl); const form = state.form; + currentController = new AbortController(); + const signal = currentController.signal; + // Return if form field sickImmunizer is a multiple select and is empty if ( form.sickImmunizer && @@ -101,10 +119,14 @@ export default { } const [result, localNames] = await Promise.all([ - api.request((csv ? `export-csv/` : `data/`) + request), + api.request((csv ? `export-csv/` : `data/`) + request, signal), api.request(isStateData) ]); + if (result.aborted) { + return result; + } + if (result.error) { this.commit( "message/ERROR", From fa58829eb18d8d93f1a3e95edd804926ed96889e Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Thu, 9 Oct 2025 12:30:53 -0300 Subject: [PATCH 12/31] Fix width select fields --- src/assets/css/style.css | 6 ++++++ src/components/sub-select.js | 2 +- src/main.js | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 270364c..b906bae 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -89,3 +89,9 @@ opacity: 0; } } + +.vbr, +.vbr ::before, +.vbr ::after { + box-sizing: unset !important; +} diff --git a/src/components/sub-select.js b/src/components/sub-select.js index f5f978c..8269c80 100644 --- a/src/components/sub-select.js +++ b/src/components/sub-select.js @@ -126,7 +126,7 @@ export const subSelect = { } } - const styleWidth = props.modal ? "width: 400px;" : "width: 225px;"; + const styleWidth = props.modal ? "width: 400px;" : "width: 200px;"; watch( () => store.state.content.form.local, diff --git a/src/main.js b/src/main.js index 940138b..e26b97b 100644 --- a/src/main.js +++ b/src/main.js @@ -172,7 +172,7 @@ export default class MCT { }, template: ` -
+
From 3ba5f66e5464e7672641d1083da07857f6cf0ad9 Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Fri, 10 Oct 2025 21:31:57 -0300 Subject: [PATCH 13/31] New filter municipios --- src/assets/css/components/select.css | 9 +- src/common.js | 6 + src/components/chart.js | 1 - src/components/main-card.js | 32 +++- src/components/sub-select.js | 177 ++++++++++++++----- src/store/modules/content/actions.js | 8 +- src/store/modules/content/getDefaultState.js | 2 + src/store/modules/content/mutations.js | 7 +- src/utils.js | 6 +- 9 files changed, 191 insertions(+), 57 deletions(-) diff --git a/src/assets/css/components/select.css b/src/assets/css/components/select.css index 8d50a7a..e1efa81 100644 --- a/src/assets/css/components/select.css +++ b/src/assets/css/components/select.css @@ -75,6 +75,10 @@ */ +.n-form-item-feedback-wrapper { + min-height: 0 !important; +} + .start-datepicker .n-base-selection__border { border-right-color: transparent; } @@ -114,10 +118,10 @@ display:flex; justify-content: center; gap: 14px; - align-items: center; + align-items: flex-start; max-width: 1368px; margin: 0px auto; - padding: 0px 24px; + padding: 0px 24px 24px; } .mct-selects--modal { @@ -133,5 +137,6 @@ background-color: white; gap: 12px; box-shadow: none; + align-items: center; } } diff --git a/src/common.js b/src/common.js index 0c3eeb1..d1afde2 100644 --- a/src/common.js +++ b/src/common.js @@ -12,12 +12,18 @@ export const formatToApi = ({ routerResult[formField] = form[formField]; } break; + case "city": + if (form[formField] && form[formField].length) { + routerResult[formField] = form[formField]; + } + break; case "periodEnd": case "periodStart": if (form[formField]) { routerResult[formField] = form[formField]; } break; + case "cities": case "doses": case "granularities": case "immunizers": diff --git a/src/components/chart.js b/src/components/chart.js index 4c67523..07d4661 100644 --- a/src/components/chart.js +++ b/src/components/chart.js @@ -116,7 +116,6 @@ export const chart = { if (label.includes(",")) { labelSplited = label.split(","); - lastLabel = labelSplited[1].split(" ")[0] + " " + labelSplited[1].split(" ")[2].substr(0, 3); } return `${labelAcronym} ${lastLabel}`; } diff --git a/src/components/main-card.js b/src/components/main-card.js index 2b9665b..e7d610e 100644 --- a/src/components/main-card.js +++ b/src/components/main-card.js @@ -122,6 +122,15 @@ export const mainCard = { } else { removeQueryFromRouter(key); } + } else if (key === "city") { + // TODO: define if cities will be in URL state + // const values = value.split(",") + // const cities = formState["cities"].map(el => el.value) + // if (values.every(val => cities.includes(val))) { + // routerResult[key] = values; + // } else { + // removeQueryFromRouter(key); + // } } else if (key === "local") { const values = value.split(",") const locals = formState["locals"].map(el => el.value) @@ -178,6 +187,11 @@ export const mainCard = { if (Array.isArray(stateResult.local) && stateResult.local.length) { stateResult.local = [...stateResult?.local].join(","); } + // TODO: define if cities will be in URL state + // if (Array.isArray(stateResult.city) && stateResult.city.length) { + // stateResult.city = [...stateResult?.city].join(","); + // } + delete stateResult.city if (!JSON.stringify(routeArgs) == JSON.stringify(stateResult)) { return; @@ -188,9 +202,21 @@ export const mainCard = { watch(() => { const form = store.state.content.form; - return [form.sickImmunizer, form.type, form.dose, form.local, - form.period, form.periodStart, form.periodEnd, - form.granularity, store.state.content.tab, store.state.content.tabBy] + return [ + form.dose, + form.granularity, + form.granularity, + form.local, + form.period, + form.periodEnd, + form.periodStart, + form.sickImmunizer, + form.type, + // TODO: define if cities will be in URL state + // form.city, + store.state.content.tab, + store.state.content.tabBy + ] }, async () => { setUrlFromState(); diff --git a/src/components/sub-select.js b/src/components/sub-select.js index 8269c80..5528ef6 100644 --- a/src/components/sub-select.js +++ b/src/components/sub-select.js @@ -1,4 +1,4 @@ -import { ref, watch, computed, toRaw, onBeforeMount, h } from "vue/dist/vue.esm-bundler"; +import { ref, watch, computed, toRaw, onBeforeMount, onMounted, h, reactive, nextTick } from "vue/dist/vue.esm-bundler"; import { NSelect, NFormItem, NDatePicker, NButton, NTooltip, NIcon } from "naive-ui"; import { useStore } from 'vuex'; import { computedVar } from "../utils"; @@ -24,6 +24,7 @@ export const subSelect = { const tabBy = computed(() => store.state.content.tabBy); const sickTemp = ref(null); const localTemp = ref(null); + const citiesTemp = ref([]); const disableLocalSelect = computed(() => store.getters[`content/disableLocalSelect`]); const sick = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "sickImmunizer" })); const sicks = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "sicks" })); @@ -40,6 +41,13 @@ export const subSelect = { const periodStart = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "periodStart" })); const periodEnd = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "periodEnd" })) const years = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "years" })) + const city = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "city" })) + const cities = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "cities" })) + const selectRefsMap = reactive({}); + const resizeObserver = ref(null); + + const activeSelectKey = ref(null); + const formRef = ref(null); const showingLocalsOptions = ref(null); const showingSicksOptions = ref(null); @@ -128,10 +136,29 @@ export const subSelect = { const styleWidth = props.modal ? "width: 400px;" : "width: 200px;"; + watch( + () => props.modal, + () => { + const loc = store.state.content.form.local; + if (loc) { + citiesTemp.value = cities.value.filter(city => loc.includes(city.uf)); + } + }, + { deep: true, immediate: true } + ) + watch( () => store.state.content.form.local, (loc) => { - localTemp.value = loc + if (!loc.length) { + city.value = []; + } else { + citiesTemp.value = cities.value.filter(city => loc.includes(city.uf)); + if (city.value?.length) { + city.value = city.value.filter(itemA => citiesTemp.value.find(itemB => itemB.value === itemA)); + } + } + localTemp.value = loc; } ); @@ -147,38 +174,71 @@ export const subSelect = { localTemp.value = store.state.content.form.local; }); + const handleShowUpdate = (show, key) => { + if (show) { + activeSelectKey.value = key; + } else if (activeSelectKey.value === key) { + activeSelectKey.value = null; + } + }; + + const updateDropdownPosition = () => { + const key = activeSelectKey.value; + + if (key && selectRefsMap[key]) { + const activeSelect = selectRefsMap[key]; + activeSelect.blur(); + nextTick(() => { + activeSelect.handleTriggerClick(); + }); + } + }; + + onMounted(() => { + if (formRef.value) { + resizeObserver.value = new ResizeObserver(updateDropdownPosition); + resizeObserver.value.observe(formRef.value.closest('.main')); + } + }); + return { - selectAllLocals, + biEraser, + cities, + citiesTemp, + city, + clear, + disableAll: computed(() => store.state.content.yearSlideAnimation), + disableLocalSelect, + dose, + doses, + eraseForm, + formRef, + granularities, + granularity, handleLocalsUpdateShow, handleLocalsUpdateValue, + handleShowUpdate, handleSicksUpdateShow, handleSicksUpdateValue, - type, - types, + immunizers, local, + localTemp, locals, - dose, - doses, - sick, - sicks, - periodStart, - periodEnd, period, - years, - granularity, - granularities, - localTemp, + periodEnd, + periodStart, + selectAllLocals, + selectRefsMap, + sick, sickTemp, - updateDatePosition, + sicks, + styleWidth, tab, tabBy, - immunizers, - biEraser, - eraseForm, - clear, - disableAll: computed(() => store.state.content.yearSlideAnimation), - disableLocalSelect, - styleWidth, + type, + types, + updateDatePosition, + years, modalContentGlossary: computed(() => { const text = store.state.content.about; let result = ""; @@ -214,9 +274,10 @@ export const subSelect = { } }, template: ` -
+
- - +
+ + + + + + +
+ + + + - - -
`, } diff --git a/src/store/modules/content/actions.js b/src/store/modules/content/actions.js index 3616a6e..e04cd30 100644 --- a/src/store/modules/content/actions.js +++ b/src/store/modules/content/actions.js @@ -33,8 +33,12 @@ export default { return; } for (let [key, value] of Object.entries(options)) { - value.sort(); - payload[key] = value.map(x => { return { label: x, value: x } }); + if (key === 'cities') { + payload[key] = value.map(item => { return { ...item, label: `${item.uf} - ${item.nome}`, value: item.codigo6 } }); + } else { + value.sort(); + payload[key] = value.map(item => { return { label: item, value: item } }); + } } // Select all in locals select payload.locals.unshift({ label: "Todos", value: "Todos" }); diff --git a/src/store/modules/content/getDefaultState.js b/src/store/modules/content/getDefaultState.js index f4239b6..e101e75 100644 --- a/src/store/modules/content/getDefaultState.js +++ b/src/store/modules/content/getDefaultState.js @@ -20,6 +20,8 @@ export const getDefaultState = () => { periodEnd: null, granularity: null, granularities: [], + city: null, + cities: [] }, yearSlideAnimation: false, autoFilters: null, diff --git a/src/store/modules/content/mutations.js b/src/store/modules/content/mutations.js index 3d1f996..e5f2e18 100644 --- a/src/store/modules/content/mutations.js +++ b/src/store/modules/content/mutations.js @@ -83,20 +83,15 @@ export default { state.form.granularity === "Municípios" && state.form.local.length > 1 ) { - if (["map", "chart"].includes(state.tab)) { + if (["map"].includes(state.tab)) { this.commit("content/UPDATE_TAB", { tab: "table" }); } state.disableMap = true; - state.disableChart = true; } else if ( state.form.granularity === "Municípios" || state.form.type === "Meta atingida" ) { - if (state.tab === "chart") { - this.commit("content/UPDATE_TAB", { tab: "table" }); - } state.disableMap = false; - state.disableChart = true; } else { state.disableMap = false; state.disableChart = false; diff --git a/src/utils.js b/src/utils.js index a68642f..6292668 100644 --- a/src/utils.js +++ b/src/utils.js @@ -311,8 +311,10 @@ export const disableOptionsByDoseOrSick = (state, payload) => { ); } else { resultToBlock = blockedListRows.find(blr => - blr[listIndexSickImmuno] === selectedValue && - blr[listIndexType].toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') === type + { + return blr[listIndexSickImmuno] === selectedValue && + blr[listIndexType].toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') === type + } ); } From 865482469565248b884b1c1e319294a23f421983 Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Sat, 11 Oct 2025 17:31:56 -0300 Subject: [PATCH 14/31] Remove selects animations --- src/assets/css/style.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index b906bae..0c9f284 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -95,3 +95,15 @@ .vbr ::after { box-sizing: unset !important; } + +/* Remove fade/scale selects animations */ +.fade-in-scale-up-transition-enter-active, +.fade-in-scale-up-transition-leave-active { + transition: none !important; +} + +.fade-in-scale-up-transition-enter-from, +.fade-in-scale-up-transition-leave-to { + opacity: 1 !important; + transform: none !important; +} From 79154799c2d1d75acaf290b86281db6356368bde Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Sat, 11 Oct 2025 17:32:44 -0300 Subject: [PATCH 15/31] Update select setups with new cities selector --- src/components/chart.js | 21 ++++- src/components/config.js | 11 ++- src/components/sub-select.js | 134 ++++++++++++++++++++++++--- src/main.js | 2 +- src/store/modules/content/actions.js | 2 - 5 files changed, 148 insertions(+), 22 deletions(-) diff --git a/src/components/chart.js b/src/components/chart.js index 07d4661..72d3129 100644 --- a/src/components/chart.js +++ b/src/components/chart.js @@ -114,8 +114,12 @@ export const chart = { undefined; let labelAcronym = acronym ? acronym["sigla_vacinabr"] : (labelSplited[0].substr(0, 3) + "."); - if (label.includes(",")) { + if (store.state.content.form.granularity.toLowerCase() === 'municípios'){ + labelSplited = label.split(","); + lastLabel = " " + labelSplited[1] + ", " + labelSplited[2].substr(0, 6) + "."; + } else if (label.includes(",")) { labelSplited = label.split(","); + lastLabel = labelSplited[1].split(" ")[0] + " " + labelSplited[1].split(" ")[2].substr(0, 3); } return `${labelAcronym} ${lastLabel}`; } @@ -172,11 +176,15 @@ export const chart = { if (store.state.content.form.type !== "Doses aplicadas") { signal = "%"; for (const dataset of datasets) { - dataset.data = dataset.data.map(number => Number(number).toFixed(2)); + dataset.data = dataset.data.map((number, index) => { + return Number(number).toFixed(2) + }); } } else { for (const dataset of datasets) { - dataset.data = dataset.data.map(number => number ? Number(number.replace(/\./g, "")) : number); + dataset.data = dataset.data.map((number, index) => { + return number ? Number(number.replace(/\./g, "")) : number + }); } } @@ -415,7 +423,12 @@ export const chart = { const dataChart = []; let i = 0; - for(let [key, value] of chartResultEntries) { + + for (let [key, value] of chartResultEntries) { + if (i > 99) { + store.commit('message/INFO', "Essa filtragem excedeu o máximo de 100 linhas") + break; + } const color = colors[i % colors.length]; dataChart.push({ label: key, diff --git a/src/components/config.js b/src/components/config.js index df9313b..ceb0099 100644 --- a/src/components/config.js +++ b/src/components/config.js @@ -2,7 +2,7 @@ import { NConfigProvider, ptBR } from "naive-ui"; export const config = { components: { - NConfigProvider, + NConfigProvider }, setup () { const lightThemeOverrides = { @@ -32,6 +32,15 @@ export const config = { thFontWeight: "500", thIconColor: "#e96f5f", }, + Select: { + peers: { + InternalSelectMenu: { + clearTransition: null, + fadeTransition: null, + slideUpTransition: null, + } + } + } }; return { // Config-provider setup diff --git a/src/components/sub-select.js b/src/components/sub-select.js index 5528ef6..a8f103d 100644 --- a/src/components/sub-select.js +++ b/src/components/sub-select.js @@ -22,6 +22,7 @@ export const subSelect = { const store = useStore(); const tab = computed(() => store.state.content.tab); const tabBy = computed(() => store.state.content.tabBy); + const cityTemp = ref(null); const sickTemp = ref(null); const localTemp = ref(null); const citiesTemp = ref([]); @@ -66,22 +67,25 @@ export const subSelect = { } } - const selectAllLocals = (options) => { - const allOptions = toRaw(options).filter((option) => option.value !== "Todos") + const selectAllLocals = (field) => { + const allOptions = toRaw(locals.value); const selectLength = Array.isArray(localTemp.value) ? localTemp.value.length : null if (selectLength == allOptions.length) { localTemp.value = []; + handleShowUpdate(true, field); return; } - localTemp.value = allOptions.map(x => x.value); + localTemp.value = allOptions.map(option => option.value); + handleShowUpdate(true, field); } - const handleLocalsUpdateShow = (show) => { + const handleLocalsUpdateShow = (show, field) => { showingLocalsOptions.value = show; if (!showingLocalsOptions.value && localTemp.value) { local.value = localTemp.value; } + handleShowUpdate(show, field); }; const handleLocalsUpdateValue = (value) => { @@ -100,12 +104,13 @@ export const subSelect = { } }; - const handleSicksUpdateShow = (show) => { + const handleSicksUpdateShow = (show, field) => { showingSicksOptions.value = show; if (!showingSicksOptions.value && sickTemp.value && tab.value !== "map") { sick.value = sickTemp.value; } + handleShowUpdate(show, field); }; const handleSicksUpdateValue = (value) => { @@ -149,16 +154,31 @@ export const subSelect = { watch( () => store.state.content.form.local, - (loc) => { + async (loc) => { if (!loc.length) { city.value = []; + cityTemp.value = []; + cities.value.forEach(item => { + item.disabled = false; + item.disabledText = "" + }); } else { citiesTemp.value = cities.value.filter(city => loc.includes(city.uf)); if (city.value?.length) { city.value = city.value.filter(itemA => citiesTemp.value.find(itemB => itemB.value === itemA)); + cityTemp.value = city.value; } + disableStateCitiesSelector(cityTemp.value); } localTemp.value = loc; + await showCitiesSelectUpdate(); + } + ); + + watch( + () => tab.value, + () => { + disableStateCitiesSelector(cityTemp.value); } ); @@ -169,6 +189,13 @@ export const subSelect = { } ); + watch( + () => granularity.value, + async () => { + await showCitiesSelectUpdate(); + } + ); + onBeforeMount(() => { sickTemp.value = store.state.content.form.sickImmunizer; localTemp.value = store.state.content.form.local; @@ -185,8 +212,9 @@ export const subSelect = { const updateDropdownPosition = () => { const key = activeSelectKey.value; - if (key && selectRefsMap[key]) { - const activeSelect = selectRefsMap[key]; + const selectedRef = selectRefsMap[key]; + if (key && selectedRef) { + const activeSelect = selectedRef; activeSelect.blur(); nextTick(() => { activeSelect.handleTriggerClick(); @@ -194,18 +222,89 @@ export const subSelect = { } }; + const disableStateCitiesSelector = (value) => { + if (tab.value === "table") { + city.value = value; + if (cities.value.some(item => item.disabled === true)) { + cities.value.forEach(item => { + item.disabled = false; + item.disabledText = "" + }); + } + return; + } + + if (!value) { + return; + } + + const valueLength = value.length; + + const maxSelection = 100; + + if (valueLength <= maxSelection) { + city.value = value; + if (cities.value.some(item => item.disabled === true)) { + cities.value.forEach(item => { + item.disabled = false; + item.disabledText = "" + }); + } + if (valueLength === maxSelection) { + cities.value.forEach(item => { + if (!value.includes(item.codigo6)) { + item.disabled = true; + item.disabledText = "Limite de seleções atingido" + } + }); + } + } + + if (valueLength >= maxSelection) { + city.value = value.slice(0, maxSelection); + cityTemp.value = city.value; + store.commit("message/INFO", "Valores de seletor de municípios foram atualizado para limites de gráfico"); + } + + } + + const handleCitiesUpdateValue = (value) => { + disableStateCitiesSelector(value); + + return; + } + onMounted(() => { if (formRef.value) { resizeObserver.value = new ResizeObserver(updateDropdownPosition); resizeObserver.value.observe(formRef.value.closest('.main')); } }); + const wait = (timeInMs) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, timeInMs); + }); + } + + const showCitiesSelect = ref(false); + + const showCitiesSelectUpdate = async () => { + await wait(100); + if (granularity.value.toLowerCase() === 'municípios' && local.value.length) { + showCitiesSelect.value = true; + return; + } + showCitiesSelect.value = false; + } return { biEraser, cities, citiesTemp, city, + cityTemp, clear, disableAll: computed(() => store.state.content.yearSlideAnimation), disableLocalSelect, @@ -215,6 +314,7 @@ export const subSelect = { formRef, granularities, granularity, + handleCitiesUpdateValue, handleLocalsUpdateShow, handleLocalsUpdateValue, handleShowUpdate, @@ -239,6 +339,7 @@ export const subSelect = { types, updateDatePosition, years, + showCitiesSelect, modalContentGlossary: computed(() => { const text = store.state.content.about; let result = ""; @@ -285,7 +386,7 @@ export const subSelect = { :style="styleWidth" :consistent-menu-width="false" :multiple="tab !== 'map'" - :on-update:show="handleSicksUpdateShow" + :on-update:show="show => handleSicksUpdateShow(show, 'field1')" :on-update:value="handleSicksUpdateValue" :options="tabBy === 'sicks' ? sicks : immunizers" :placeholder="'Selecione ' + (tabBy === 'sicks' ? 'Doença' : 'Vacina')" @@ -293,7 +394,6 @@ export const subSelect = { clearable :disabled="disableAll" :on-clear="() => clear('sickImmunizer')" - @update:show="show => handleShowUpdate(show, 'field1')" /> @@ -343,10 +443,14 @@ export const subSelect = { filterable :disabled="disableAll || disableLocalSelect" max-tag-count="responsive" - :on-update:show="handleLocalsUpdateShow" + :on-update:show="show => handleLocalsUpdateShow(show, 'field4')" :on-update:value="handleLocalsUpdateValue" - @update:show="show => handleShowUpdate(show, 'field4')" > + @@ -393,21 +497,23 @@ export const subSelect = { @update:show="show => handleShowUpdate(show, 'field7')" /> - +
diff --git a/src/main.js b/src/main.js index e26b97b..b638620 100644 --- a/src/main.js +++ b/src/main.js @@ -1,5 +1,5 @@ import "./assets/css/style.css"; -import { createApp, computed, ref, onBeforeMount } from "vue/dist/vue.esm-bundler"; +import { createApp, computed, onBeforeMount } from "vue/dist/vue.esm-bundler"; import logo from "./assets/images/logo-vacinabr.svg"; import store from "./store/"; import { config as Config } from "./components/config"; diff --git a/src/store/modules/content/actions.js b/src/store/modules/content/actions.js index e04cd30..7a85b41 100644 --- a/src/store/modules/content/actions.js +++ b/src/store/modules/content/actions.js @@ -40,8 +40,6 @@ export default { payload[key] = value.map(item => { return { label: item, value: item } }); } } - // Select all in locals select - payload.locals.unshift({ label: "Todos", value: "Todos" }); commit("UPDATE_FORM_SELECTS", payload); }, async requestData( From 400147cf218ba4c00f3bb672cb62f460696f852a Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Mon, 13 Oct 2025 14:22:15 -0300 Subject: [PATCH 16/31] Enahnce filter and select behaviors --- src/components/chart.js | 2 +- src/components/sub-select.js | 110 +++++++++++++++++++++++++++-------- src/data-fetcher.js | 44 ++++++++++++-- 3 files changed, 127 insertions(+), 29 deletions(-) diff --git a/src/components/chart.js b/src/components/chart.js index 72d3129..3039c74 100644 --- a/src/components/chart.js +++ b/src/components/chart.js @@ -426,7 +426,7 @@ export const chart = { for (let [key, value] of chartResultEntries) { if (i > 99) { - store.commit('message/INFO', "Essa filtragem excedeu o máximo de 100 linhas") + store.commit('message/INFO', "Essa filtragem excedeu o máximo de 30 linhas, apenas 30 linhas serão exibidas") break; } const color = colors[i % colors.length]; diff --git a/src/components/sub-select.js b/src/components/sub-select.js index a8f103d..3ee2316 100644 --- a/src/components/sub-select.js +++ b/src/components/sub-select.js @@ -1,5 +1,5 @@ import { ref, watch, computed, toRaw, onBeforeMount, onMounted, h, reactive, nextTick } from "vue/dist/vue.esm-bundler"; -import { NSelect, NFormItem, NDatePicker, NButton, NTooltip, NIcon } from "naive-ui"; +import { NSelect, NFormItem, NDatePicker, NButton, NTooltip, NIcon, NSpin } from "naive-ui"; import { useStore } from 'vuex'; import { computedVar } from "../utils"; import { biEraser } from "../icons.js"; @@ -10,7 +10,8 @@ export const subSelect = { NFormItem, NDatePicker, NButton, - NIcon + NIcon, + NSpin }, props: { modal: { @@ -46,6 +47,7 @@ export const subSelect = { const cities = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "cities" })) const selectRefsMap = reactive({}); const resizeObserver = ref(null); + const isLoadingCities = ref(false); const activeSelectKey = ref(null); const formRef = ref(null); @@ -80,6 +82,27 @@ export const subSelect = { handleShowUpdate(true, field); } + const selectAllCities = (field, uncheckAll = false) => { + if (isLoadingCities.value) { + return; + } + isLoadingCities.value = true; + setTimeout(() => { + const allOptions = toRaw(citiesTemp.value); + const selectLength = Array.isArray(cityTemp.value) ? cityTemp.value.length : null + if ((selectLength == allOptions.length) || uncheckAll) { + cityTemp.value = []; + handleShowUpdate(true, field); + isLoadingCities.value = false; + return; + } + + cityTemp.value = allOptions.map(option => option.value); + handleShowUpdate(true, field); + isLoadingCities.value = false; + }, 0) + } + const handleLocalsUpdateShow = (show, field) => { showingLocalsOptions.value = show; if (!showingLocalsOptions.value && localTemp.value) { @@ -89,19 +112,15 @@ export const subSelect = { }; const handleLocalsUpdateValue = (value) => { - if (toRaw(value).includes("Todos")) { - selectAllLocals(locals.value); - return; - } - localTemp.value = value; if (!showingLocalsOptions.value && localTemp.value){ local.value = localTemp.value; } - const nPopover = document.querySelector(".n-popover"); - if (nPopover) { - nPopover.innerHTML = ""; - } + // Close hover box options remover + // const nPopover = document.querySelector(".n-popover"); + // if (nPopover) { + // nPopover.innerHTML = ""; + // } }; const handleSicksUpdateShow = (show, field) => { @@ -118,10 +137,11 @@ export const subSelect = { if (!showingSicksOptions.value && sickTemp.value) { sick.value = value; } - const nPopover = document.querySelector(".n-popover"); - if (nPopover) { - nPopover.innerHTML = ""; - } + // Close hover box options remover + // const nPopover = document.querySelector(".n-popover"); + // if (nPopover) { + // nPopover.innerHTML = ""; + // } }; const eraseForm = () => { @@ -177,8 +197,9 @@ export const subSelect = { watch( () => tab.value, - () => { + async () => { disableStateCitiesSelector(cityTemp.value); + await showCitiesSelectUpdate(); } ); @@ -240,7 +261,7 @@ export const subSelect = { const valueLength = value.length; - const maxSelection = 100; + const maxSelection = 30; if (valueLength <= maxSelection) { city.value = value; @@ -260,7 +281,7 @@ export const subSelect = { } } - if (valueLength >= maxSelection) { + if (valueLength > maxSelection) { city.value = value.slice(0, maxSelection); cityTemp.value = city.value; store.commit("message/INFO", "Valores de seletor de municípios foram atualizado para limites de gráfico"); @@ -292,13 +313,30 @@ export const subSelect = { const showCitiesSelectUpdate = async () => { await wait(100); - if (granularity.value.toLowerCase() === 'municípios' && local.value.length) { + const granValue = granularity.value; + if ( + (granValue && granValue.toLowerCase() === 'municípios') && + local.value.length && + tab.value !== 'map' + ) { showCitiesSelect.value = true; return; } showCitiesSelect.value = false; } + const removeAccents = (str) => { + return str.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + }; + + const customFilter = (pattern, option) => { + const optionLabel = option.label || ''; + const normalizedPattern = removeAccents(pattern).toLowerCase(); + const normalizedLabel = removeAccents(optionLabel).toLowerCase(); + + return normalizedLabel.includes(normalizedPattern); + }; + return { biEraser, cities, @@ -306,6 +344,7 @@ export const subSelect = { city, cityTemp, clear, + customFilter, disableAll: computed(() => store.state.content.yearSlideAnimation), disableLocalSelect, dose, @@ -321,12 +360,14 @@ export const subSelect = { handleSicksUpdateShow, handleSicksUpdateValue, immunizers, + isLoadingCities, local, localTemp, locals, period, periodEnd, periodStart, + selectAllCities, selectAllLocals, selectRefsMap, sick, @@ -447,9 +488,11 @@ export const subSelect = { :on-update:value="handleLocalsUpdateValue" > @@ -514,7 +557,28 @@ export const subSelect = { v-model:value="cityTemp" :multiple="true" :render-option="renderOption" - /> + :filter="customFilter" + > + + +
diff --git a/src/data-fetcher.js b/src/data-fetcher.js index fe60e90..b034f66 100644 --- a/src/data-fetcher.js +++ b/src/data-fetcher.js @@ -3,7 +3,7 @@ export class DataFetcher { this.api = api; } - async requestData(endPoint, apiPoint = "/wp-json/api/v1/", signal) { + async requestData(endPoint, apiPoint = "/wp-json/api/v1/", signal) { try { const args = [this.api + apiPoint + endPoint]; if (signal) { @@ -20,12 +20,46 @@ export class DataFetcher { } } - async request(endPoint) { - const result = await this.requestData(endPoint); - return result; + // TODO: Mabe we will need to do more complex filters with POST requests with args in request body + async requestDataInBody(endPoint, options = {}, apiPoint = "/wp-json/api/v1/") { + const { signal, body, method = 'GET', headers: customHeaders = {} } = options; + + console.log({ api: this.api, apiPoint, endPoint }); + const url = this.api + apiPoint + endPoint; + + const fetchOptions = { + method, + signal, + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...customHeaders, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }; + + try { + // Remove keys with undefined values + Object.keys(fetchOptions).forEach(key => fetchOptions[key] === undefined && delete fetchOptions[key]); + + const response = await fetch(url, fetchOptions); + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return await response.json(); + } + + return response.text(); + + } catch (error) { + console.log(error) + if (error.name === 'AbortError') { + return { aborted: true }; + } + return error; + } } - async request(endPoint, signal) { + async request(endPoint, signal = undefined) { const args = [endPoint, "/wp-json/api/v1/"]; if (signal) { args.push(signal); From eeb1fe8a44ee341a1ecec665be85324aabd48a6a Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Mon, 13 Oct 2025 14:24:09 -0300 Subject: [PATCH 17/31] Update version --- package.json | 2 +- src/components/sub-select.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c643f98..40dbb74 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dashboard", "private": true, - "version": "1.5.2", + "version": "1.6.0", "type": "module", "main": "dist/dashboard.js", "scripts": { diff --git a/src/components/sub-select.js b/src/components/sub-select.js index 3ee2316..956a7d3 100644 --- a/src/components/sub-select.js +++ b/src/components/sub-select.js @@ -540,7 +540,7 @@ export const subSelect = { @update:show="show => handleShowUpdate(show, 'field7')" /> - + Date: Mon, 13 Oct 2025 16:01:03 -0300 Subject: [PATCH 18/31] Fix to filter only if field exists --- src/utils.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils.js b/src/utils.js index 6292668..0ce37ee 100644 --- a/src/utils.js +++ b/src/utils.js @@ -287,7 +287,7 @@ export const disableOptionsByDoseOrSick = (state, payload) => { const listIndex = blockedListHeader.findIndex(el => el === blockHeaderName(selectedValue)); for (let i=0; i < sicksImmunizers.length; i++) { const blockedListRow = blockedListRows.find(blr => - blr[listIndexSickImmuno] === sicksImmunizers[i].label && + blr[listIndexSickImmuno] === sicksImmunizers[i].label && blr[listIndexType] && blr[listIndexType].toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') === type ); const disabled = blockedListRow && blockedListRow[listIndex] === false ? true : false; @@ -306,13 +306,13 @@ export const disableOptionsByDoseOrSick = (state, payload) => { if (Array.isArray(selectedValue)) { resultToBlock = blockedListRows.filter(blr => - selectedValue.includes(blr[listIndexSickImmuno]) && + selectedValue.includes(blr[listIndexSickImmuno]) && blr[listIndexType] && blr[listIndexType].toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') === type ); } else { resultToBlock = blockedListRows.find(blr => { - return blr[listIndexSickImmuno] === selectedValue && + return blr[listIndexSickImmuno] === selectedValue && blr[listIndexType] && blr[listIndexType].toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') === type } ); From 506007fba17f372988961eca91c5f7079135c11e Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Fri, 31 Oct 2025 11:22:30 -0300 Subject: [PATCH 19/31] Update behavior mun selecct --- src/components/sub-select.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) mode change 100644 => 100755 src/components/sub-select.js diff --git a/src/components/sub-select.js b/src/components/sub-select.js old mode 100644 new mode 100755 index 956a7d3..ff95913 --- a/src/components/sub-select.js +++ b/src/components/sub-select.js @@ -87,6 +87,8 @@ export const subSelect = { return; } isLoadingCities.value = true; + + // We use setTimeout to run this code after Vue render process setTimeout(() => { const allOptions = toRaw(citiesTemp.value); const selectLength = Array.isArray(cityTemp.value) ? cityTemp.value.length : null @@ -177,7 +179,7 @@ export const subSelect = { async (loc) => { if (!loc.length) { city.value = []; - cityTemp.value = []; + citiesTemp.value = cities.value; cities.value.forEach(item => { item.disabled = false; item.disabledText = "" @@ -195,6 +197,13 @@ export const subSelect = { } ); + watch( + () => store.state.content.form.cities, + (cities) => { + citiesTemp.value = cities; + } + ) + watch( () => tab.value, async () => { @@ -316,7 +325,6 @@ export const subSelect = { const granValue = granularity.value; if ( (granValue && granValue.toLowerCase() === 'municípios') && - local.value.length && tab.value !== 'map' ) { showCitiesSelect.value = true; From 65b4d08cab009a313c37c150c2137d9f38666f90 Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Mon, 3 Nov 2025 15:09:36 -0300 Subject: [PATCH 20/31] Update behavior municio select --- src/components/sub-select.js | 147 +++++++++++++++++++++-------------- 1 file changed, 87 insertions(+), 60 deletions(-) diff --git a/src/components/sub-select.js b/src/components/sub-select.js index ff95913..6f015f6 100755 --- a/src/components/sub-select.js +++ b/src/components/sub-select.js @@ -1,5 +1,5 @@ import { ref, watch, computed, toRaw, onBeforeMount, onMounted, h, reactive, nextTick } from "vue/dist/vue.esm-bundler"; -import { NSelect, NFormItem, NDatePicker, NButton, NTooltip, NIcon, NSpin } from "naive-ui"; +import { NSelect, NFormItem, NDatePicker, NButton, NTooltip, NIcon, NSpin, NSpace } from "naive-ui"; import { useStore } from 'vuex'; import { computedVar } from "../utils"; import { biEraser } from "../icons.js"; @@ -11,7 +11,8 @@ export const subSelect = { NDatePicker, NButton, NIcon, - NSpin + NSpin, + NSpace }, props: { modal: { @@ -20,6 +21,7 @@ export const subSelect = { }, }, setup (props) { + const allCitiesValues = []; const store = useStore(); const tab = computed(() => store.state.content.tab); const tabBy = computed(() => store.state.content.tabBy); @@ -48,6 +50,7 @@ export const subSelect = { const selectRefsMap = reactive({}); const resizeObserver = ref(null); const isLoadingCities = ref(false); + const firstLoadCities = ref(true); const activeSelectKey = ref(null); const formRef = ref(null); @@ -92,17 +95,21 @@ export const subSelect = { setTimeout(() => { const allOptions = toRaw(citiesTemp.value); const selectLength = Array.isArray(cityTemp.value) ? cityTemp.value.length : null + if ((selectLength == allOptions.length) || uncheckAll) { + city.value = []; cityTemp.value = []; handleShowUpdate(true, field); isLoadingCities.value = false; return; } - cityTemp.value = allOptions.map(option => option.value); + city.value = allCitiesValues; + cityTemp.value = allCitiesValues; + handleShowUpdate(true, field); isLoadingCities.value = false; - }, 0) + }, 0); } const handleLocalsUpdateShow = (show, field) => { @@ -176,31 +183,50 @@ export const subSelect = { watch( () => store.state.content.form.local, - async (loc) => { - if (!loc.length) { - city.value = []; - citiesTemp.value = cities.value; - cities.value.forEach(item => { - item.disabled = false; - item.disabledText = "" - }); - } else { - citiesTemp.value = cities.value.filter(city => loc.includes(city.uf)); - if (city.value?.length) { - city.value = city.value.filter(itemA => citiesTemp.value.find(itemB => itemB.value === itemA)); - cityTemp.value = city.value; - } - disableStateCitiesSelector(cityTemp.value); - } + (loc) => { localTemp.value = loc; - await showCitiesSelectUpdate(); + + isLoadingCities.value = true; + + setTimeout(async () => { + if (!loc.length) { + citiesTemp.value = cities.value; + cityTemp.value = []; + city.value = []; + } else { + const rawCities = toRaw(cities.value); + const locSet = new Set(loc); + + citiesTemp.value = rawCities.filter(city => locSet.has(city.uf)); + + if (city.value?.length) { + const citiesTempSet = new Set(citiesTemp.value.map(item => item.value)); + + const rawCityValue = toRaw(city.value); + + city.value = rawCityValue.filter(itemA => citiesTempSet.has(itemA)); + cityTemp.value = city.value; + } + + disableStateCitiesSelector(cityTemp.value); + } + + await showCitiesSelectUpdate(); + isLoadingCities.value = false; + }, 0); } ); watch( () => store.state.content.form.cities, (cities) => { - citiesTemp.value = cities; + if (firstLoadCities.value) { + citiesTemp.value = cities; + firstLoadCities.value = false; + for (let i = 0; i < cities.length; i++) { + allCitiesValues.push(cities[i].value); + } + } } ) @@ -549,44 +575,45 @@ export const subSelect = { /> - - - - + + + + + +
From 27772d67b3516096250d3b5993d902067ed43c61 Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Sat, 15 Nov 2025 12:29:51 -0300 Subject: [PATCH 21/31] Move old code to old folder --- {src => oldSrc}/assets/css/base.css | 0 .../assets/css/components/collapsable.css | 0 {src => oldSrc}/assets/css/components/container.css | 0 .../assets/css/components/filter-suggestion.css | 0 {src => oldSrc}/assets/css/components/label.css | 0 .../assets/css/components/main-content.css | 0 .../assets/css/components/main-footer.css | 0 .../assets/css/components/main-header.css | 0 {src => oldSrc}/assets/css/components/modal.css | 0 {src => oldSrc}/assets/css/components/select.css | 0 {src => oldSrc}/assets/css/components/slider.css | 0 {src => oldSrc}/assets/css/components/tab.css | 0 {src => oldSrc}/assets/css/components/table.css | 0 .../assets/css/map-chart-table-legend.css | 0 {src => oldSrc}/assets/css/map-chart-table.css | 0 {src => oldSrc}/assets/css/style.css | 0 {src => oldSrc}/assets/images/abandono.svg | 0 {src => oldSrc}/assets/images/cc.png | Bin {src => oldSrc}/assets/images/cobertura.svg | 0 {src => oldSrc}/assets/images/hom_geo.svg | 0 {src => oldSrc}/assets/images/hom_vac.svg | 0 {src => oldSrc}/assets/images/legend.png | Bin {src => oldSrc}/assets/images/logo-vacinabr.svg | 0 {src => oldSrc}/assets/images/meta.svg | 0 {src => oldSrc}/assets/images/ri-alert-line.svg | 0 {src => oldSrc}/assets/images/sbim.png | Bin {src => oldSrc}/canvas-download.js | 0 {src => oldSrc}/common.js | 0 {src => oldSrc}/components/chart.js | 0 {src => oldSrc}/components/config.js | 0 {src => oldSrc}/components/filter-suggestion.js | 0 {src => oldSrc}/components/main-card.js | 0 {src => oldSrc}/components/map/map-range.js | 0 {src => oldSrc}/components/map/map.js | 0 {src => oldSrc}/components/map/year-slider.js | 0 {src => oldSrc}/components/modal.js | 0 {src => oldSrc}/components/modalGeneric.js | 0 {src => oldSrc}/components/modalWithTabs.js | 0 {src => oldSrc}/components/sub-buttons.js | 0 {src => oldSrc}/components/sub-select.js | 0 {src => oldSrc}/components/table.js | 0 {src => oldSrc}/data-fetcher.js | 0 {src => oldSrc}/icons.js | 0 {src => oldSrc}/main.js | 0 {src => oldSrc}/map-chart.js | 0 {src => oldSrc}/router/index.js | 0 {src => oldSrc}/store/index.js | 0 {src => oldSrc}/store/modules/content/actions.js | 0 .../store/modules/content/getDefaultState.js | 0 {src => oldSrc}/store/modules/content/getters.js | 0 {src => oldSrc}/store/modules/content/index.js | 0 {src => oldSrc}/store/modules/content/mutations.js | 0 {src => oldSrc}/store/modules/message.module.js | 0 {src => oldSrc}/utils.js | 0 54 files changed, 0 insertions(+), 0 deletions(-) rename {src => oldSrc}/assets/css/base.css (100%) rename {src => oldSrc}/assets/css/components/collapsable.css (100%) rename {src => oldSrc}/assets/css/components/container.css (100%) rename {src => oldSrc}/assets/css/components/filter-suggestion.css (100%) rename {src => oldSrc}/assets/css/components/label.css (100%) rename {src => oldSrc}/assets/css/components/main-content.css (100%) rename {src => oldSrc}/assets/css/components/main-footer.css (100%) rename {src => oldSrc}/assets/css/components/main-header.css (100%) rename {src => oldSrc}/assets/css/components/modal.css (100%) rename {src => oldSrc}/assets/css/components/select.css (100%) rename {src => oldSrc}/assets/css/components/slider.css (100%) rename {src => oldSrc}/assets/css/components/tab.css (100%) rename {src => oldSrc}/assets/css/components/table.css (100%) rename {src => oldSrc}/assets/css/map-chart-table-legend.css (100%) rename {src => oldSrc}/assets/css/map-chart-table.css (100%) rename {src => oldSrc}/assets/css/style.css (100%) rename {src => oldSrc}/assets/images/abandono.svg (100%) rename {src => oldSrc}/assets/images/cc.png (100%) rename {src => oldSrc}/assets/images/cobertura.svg (100%) rename {src => oldSrc}/assets/images/hom_geo.svg (100%) rename {src => oldSrc}/assets/images/hom_vac.svg (100%) rename {src => oldSrc}/assets/images/legend.png (100%) rename {src => oldSrc}/assets/images/logo-vacinabr.svg (100%) rename {src => oldSrc}/assets/images/meta.svg (100%) rename {src => oldSrc}/assets/images/ri-alert-line.svg (100%) rename {src => oldSrc}/assets/images/sbim.png (100%) rename {src => oldSrc}/canvas-download.js (100%) rename {src => oldSrc}/common.js (100%) rename {src => oldSrc}/components/chart.js (100%) rename {src => oldSrc}/components/config.js (100%) rename {src => oldSrc}/components/filter-suggestion.js (100%) rename {src => oldSrc}/components/main-card.js (100%) rename {src => oldSrc}/components/map/map-range.js (100%) rename {src => oldSrc}/components/map/map.js (100%) rename {src => oldSrc}/components/map/year-slider.js (100%) rename {src => oldSrc}/components/modal.js (100%) rename {src => oldSrc}/components/modalGeneric.js (100%) rename {src => oldSrc}/components/modalWithTabs.js (100%) rename {src => oldSrc}/components/sub-buttons.js (100%) rename {src => oldSrc}/components/sub-select.js (100%) rename {src => oldSrc}/components/table.js (100%) rename {src => oldSrc}/data-fetcher.js (100%) rename {src => oldSrc}/icons.js (100%) rename {src => oldSrc}/main.js (100%) rename {src => oldSrc}/map-chart.js (100%) rename {src => oldSrc}/router/index.js (100%) rename {src => oldSrc}/store/index.js (100%) rename {src => oldSrc}/store/modules/content/actions.js (100%) rename {src => oldSrc}/store/modules/content/getDefaultState.js (100%) rename {src => oldSrc}/store/modules/content/getters.js (100%) rename {src => oldSrc}/store/modules/content/index.js (100%) rename {src => oldSrc}/store/modules/content/mutations.js (100%) rename {src => oldSrc}/store/modules/message.module.js (100%) rename {src => oldSrc}/utils.js (100%) diff --git a/src/assets/css/base.css b/oldSrc/assets/css/base.css similarity index 100% rename from src/assets/css/base.css rename to oldSrc/assets/css/base.css diff --git a/src/assets/css/components/collapsable.css b/oldSrc/assets/css/components/collapsable.css similarity index 100% rename from src/assets/css/components/collapsable.css rename to oldSrc/assets/css/components/collapsable.css diff --git a/src/assets/css/components/container.css b/oldSrc/assets/css/components/container.css similarity index 100% rename from src/assets/css/components/container.css rename to oldSrc/assets/css/components/container.css diff --git a/src/assets/css/components/filter-suggestion.css b/oldSrc/assets/css/components/filter-suggestion.css similarity index 100% rename from src/assets/css/components/filter-suggestion.css rename to oldSrc/assets/css/components/filter-suggestion.css diff --git a/src/assets/css/components/label.css b/oldSrc/assets/css/components/label.css similarity index 100% rename from src/assets/css/components/label.css rename to oldSrc/assets/css/components/label.css diff --git a/src/assets/css/components/main-content.css b/oldSrc/assets/css/components/main-content.css similarity index 100% rename from src/assets/css/components/main-content.css rename to oldSrc/assets/css/components/main-content.css diff --git a/src/assets/css/components/main-footer.css b/oldSrc/assets/css/components/main-footer.css similarity index 100% rename from src/assets/css/components/main-footer.css rename to oldSrc/assets/css/components/main-footer.css diff --git a/src/assets/css/components/main-header.css b/oldSrc/assets/css/components/main-header.css similarity index 100% rename from src/assets/css/components/main-header.css rename to oldSrc/assets/css/components/main-header.css diff --git a/src/assets/css/components/modal.css b/oldSrc/assets/css/components/modal.css similarity index 100% rename from src/assets/css/components/modal.css rename to oldSrc/assets/css/components/modal.css diff --git a/src/assets/css/components/select.css b/oldSrc/assets/css/components/select.css similarity index 100% rename from src/assets/css/components/select.css rename to oldSrc/assets/css/components/select.css diff --git a/src/assets/css/components/slider.css b/oldSrc/assets/css/components/slider.css similarity index 100% rename from src/assets/css/components/slider.css rename to oldSrc/assets/css/components/slider.css diff --git a/src/assets/css/components/tab.css b/oldSrc/assets/css/components/tab.css similarity index 100% rename from src/assets/css/components/tab.css rename to oldSrc/assets/css/components/tab.css diff --git a/src/assets/css/components/table.css b/oldSrc/assets/css/components/table.css similarity index 100% rename from src/assets/css/components/table.css rename to oldSrc/assets/css/components/table.css diff --git a/src/assets/css/map-chart-table-legend.css b/oldSrc/assets/css/map-chart-table-legend.css similarity index 100% rename from src/assets/css/map-chart-table-legend.css rename to oldSrc/assets/css/map-chart-table-legend.css diff --git a/src/assets/css/map-chart-table.css b/oldSrc/assets/css/map-chart-table.css similarity index 100% rename from src/assets/css/map-chart-table.css rename to oldSrc/assets/css/map-chart-table.css diff --git a/src/assets/css/style.css b/oldSrc/assets/css/style.css similarity index 100% rename from src/assets/css/style.css rename to oldSrc/assets/css/style.css diff --git a/src/assets/images/abandono.svg b/oldSrc/assets/images/abandono.svg similarity index 100% rename from src/assets/images/abandono.svg rename to oldSrc/assets/images/abandono.svg diff --git a/src/assets/images/cc.png b/oldSrc/assets/images/cc.png similarity index 100% rename from src/assets/images/cc.png rename to oldSrc/assets/images/cc.png diff --git a/src/assets/images/cobertura.svg b/oldSrc/assets/images/cobertura.svg similarity index 100% rename from src/assets/images/cobertura.svg rename to oldSrc/assets/images/cobertura.svg diff --git a/src/assets/images/hom_geo.svg b/oldSrc/assets/images/hom_geo.svg similarity index 100% rename from src/assets/images/hom_geo.svg rename to oldSrc/assets/images/hom_geo.svg diff --git a/src/assets/images/hom_vac.svg b/oldSrc/assets/images/hom_vac.svg similarity index 100% rename from src/assets/images/hom_vac.svg rename to oldSrc/assets/images/hom_vac.svg diff --git a/src/assets/images/legend.png b/oldSrc/assets/images/legend.png similarity index 100% rename from src/assets/images/legend.png rename to oldSrc/assets/images/legend.png diff --git a/src/assets/images/logo-vacinabr.svg b/oldSrc/assets/images/logo-vacinabr.svg similarity index 100% rename from src/assets/images/logo-vacinabr.svg rename to oldSrc/assets/images/logo-vacinabr.svg diff --git a/src/assets/images/meta.svg b/oldSrc/assets/images/meta.svg similarity index 100% rename from src/assets/images/meta.svg rename to oldSrc/assets/images/meta.svg diff --git a/src/assets/images/ri-alert-line.svg b/oldSrc/assets/images/ri-alert-line.svg similarity index 100% rename from src/assets/images/ri-alert-line.svg rename to oldSrc/assets/images/ri-alert-line.svg diff --git a/src/assets/images/sbim.png b/oldSrc/assets/images/sbim.png similarity index 100% rename from src/assets/images/sbim.png rename to oldSrc/assets/images/sbim.png diff --git a/src/canvas-download.js b/oldSrc/canvas-download.js similarity index 100% rename from src/canvas-download.js rename to oldSrc/canvas-download.js diff --git a/src/common.js b/oldSrc/common.js similarity index 100% rename from src/common.js rename to oldSrc/common.js diff --git a/src/components/chart.js b/oldSrc/components/chart.js similarity index 100% rename from src/components/chart.js rename to oldSrc/components/chart.js diff --git a/src/components/config.js b/oldSrc/components/config.js similarity index 100% rename from src/components/config.js rename to oldSrc/components/config.js diff --git a/src/components/filter-suggestion.js b/oldSrc/components/filter-suggestion.js similarity index 100% rename from src/components/filter-suggestion.js rename to oldSrc/components/filter-suggestion.js diff --git a/src/components/main-card.js b/oldSrc/components/main-card.js similarity index 100% rename from src/components/main-card.js rename to oldSrc/components/main-card.js diff --git a/src/components/map/map-range.js b/oldSrc/components/map/map-range.js similarity index 100% rename from src/components/map/map-range.js rename to oldSrc/components/map/map-range.js diff --git a/src/components/map/map.js b/oldSrc/components/map/map.js similarity index 100% rename from src/components/map/map.js rename to oldSrc/components/map/map.js diff --git a/src/components/map/year-slider.js b/oldSrc/components/map/year-slider.js similarity index 100% rename from src/components/map/year-slider.js rename to oldSrc/components/map/year-slider.js diff --git a/src/components/modal.js b/oldSrc/components/modal.js similarity index 100% rename from src/components/modal.js rename to oldSrc/components/modal.js diff --git a/src/components/modalGeneric.js b/oldSrc/components/modalGeneric.js similarity index 100% rename from src/components/modalGeneric.js rename to oldSrc/components/modalGeneric.js diff --git a/src/components/modalWithTabs.js b/oldSrc/components/modalWithTabs.js similarity index 100% rename from src/components/modalWithTabs.js rename to oldSrc/components/modalWithTabs.js diff --git a/src/components/sub-buttons.js b/oldSrc/components/sub-buttons.js similarity index 100% rename from src/components/sub-buttons.js rename to oldSrc/components/sub-buttons.js diff --git a/src/components/sub-select.js b/oldSrc/components/sub-select.js similarity index 100% rename from src/components/sub-select.js rename to oldSrc/components/sub-select.js diff --git a/src/components/table.js b/oldSrc/components/table.js similarity index 100% rename from src/components/table.js rename to oldSrc/components/table.js diff --git a/src/data-fetcher.js b/oldSrc/data-fetcher.js similarity index 100% rename from src/data-fetcher.js rename to oldSrc/data-fetcher.js diff --git a/src/icons.js b/oldSrc/icons.js similarity index 100% rename from src/icons.js rename to oldSrc/icons.js diff --git a/src/main.js b/oldSrc/main.js similarity index 100% rename from src/main.js rename to oldSrc/main.js diff --git a/src/map-chart.js b/oldSrc/map-chart.js similarity index 100% rename from src/map-chart.js rename to oldSrc/map-chart.js diff --git a/src/router/index.js b/oldSrc/router/index.js similarity index 100% rename from src/router/index.js rename to oldSrc/router/index.js diff --git a/src/store/index.js b/oldSrc/store/index.js similarity index 100% rename from src/store/index.js rename to oldSrc/store/index.js diff --git a/src/store/modules/content/actions.js b/oldSrc/store/modules/content/actions.js similarity index 100% rename from src/store/modules/content/actions.js rename to oldSrc/store/modules/content/actions.js diff --git a/src/store/modules/content/getDefaultState.js b/oldSrc/store/modules/content/getDefaultState.js similarity index 100% rename from src/store/modules/content/getDefaultState.js rename to oldSrc/store/modules/content/getDefaultState.js diff --git a/src/store/modules/content/getters.js b/oldSrc/store/modules/content/getters.js similarity index 100% rename from src/store/modules/content/getters.js rename to oldSrc/store/modules/content/getters.js diff --git a/src/store/modules/content/index.js b/oldSrc/store/modules/content/index.js similarity index 100% rename from src/store/modules/content/index.js rename to oldSrc/store/modules/content/index.js diff --git a/src/store/modules/content/mutations.js b/oldSrc/store/modules/content/mutations.js similarity index 100% rename from src/store/modules/content/mutations.js rename to oldSrc/store/modules/content/mutations.js diff --git a/src/store/modules/message.module.js b/oldSrc/store/modules/message.module.js similarity index 100% rename from src/store/modules/message.module.js rename to oldSrc/store/modules/message.module.js diff --git a/src/utils.js b/oldSrc/utils.js similarity index 100% rename from src/utils.js rename to oldSrc/utils.js From d319ca02f422a4990434669d34c63697eb7e0ff3 Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Sat, 15 Nov 2025 13:11:21 -0300 Subject: [PATCH 22/31] Update Makefile and docker setups --- Dockerfile | 10 ++++++++++ Makefile | 36 ++++++++++++++++++++++-------------- compose.yml | 8 +++----- env.example | 10 ---------- 4 files changed, 35 insertions(+), 29 deletions(-) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..35f29e2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:20-alpine + +RUN apk update && apk add bash + +RUN npm install -g npm@^9.0.0 + +WORKDIR /srv/app +COPY package.json package-lock.json /srv/app/ +RUN cd /srv/app && npm install +COPY . /srv/app/ diff --git a/Makefile b/Makefile index d51d834..9dcda61 100644 --- a/Makefile +++ b/Makefile @@ -5,34 +5,42 @@ help: ## List all make commands @echo ' ' build: ## Build the project with -d and --no-recreate flags - $(DOCKER_COMPOSE) up --build --no-recreate -d + docker compose up --build --no-recreate -d install: ## Exec container and make npm install commands - $(DOCKER_EXEC_TOOLS_APP) -c $(NODE_INSTALL) + docker compose exec mct_web npm install -bundle: ## Run build npm command script - $(DOCKER_EXEC_TOOLS_APP) -c $(BUNDLE_RUN) +bundle: + docker compose exec mct_web npm run bundle clean: ## Remove all dist/ files - $(DOCKER_EXEC_TOOLS_APP) -c $(CLEAN_RUN) + docker compose exec mct_web rm -r dist/* -interact: ## Interact to install new packages or run specific commands in container - $(DOCKER_EXEC_TOOLS_APP) +bash: ## Interact to install new packages or run specific commands in container + docker compose exec -it mct_web bash dev: # Internal command to run dev npm command script - $(DOCKER_EXEC_TOOLS_APP) -c $(SERVER_RUN) + docker compose exec -it mct_web npm run development up: ## Run up -d Docker command container will wait for interactions - $(DOCKER_COMPOSE) up -d + docker compose up -d start: up dev ## Up the docker env and run the npm run dev it to first: build install dev ## Build the env, up it and run the npm install and then run npm run dev it to -stop: $(ROOT_DIR)/compose.yml ## Stop and remove containers - $(DOCKER_COMPOSE) kill - $(DOCKER_COMPOSE) rm --force +stop: ./compose.yml ## Stop and remove containers + docker compose kill + docker compose rm --force restart: stop start dev ## Stop and restart container -clear: stop $(ROOT_DIR)/compose.yml ## Stop and remove container and orphans - $(DOCKER_COMPOSE) down -v --remove-orphans +types: ## Run type check and generator + docker compose exec mct_web npm run types + +types-watch: ## Run type check and generator + docker compose exec mct_web npm run types-watch + +clear: stop ./compose.yml ## Stop and remove container and orphans + docker compose down -v --remove-orphans + +.PHONY: bash build clean help logs start stop types types-watch diff --git a/compose.yml b/compose.yml index 9b79ef1..9661484 100644 --- a/compose.yml +++ b/compose.yml @@ -1,9 +1,7 @@ -version: "3.4" - services: - vite_docker: - image: node:alpine - container_name: "${DOCKER_NAME}" + mct_web: + build: . + image: mct:latest entrypoint: /bin/sh env_file: - .env diff --git a/env.example b/env.example index e1a49d1..45633f3 100644 --- a/env.example +++ b/env.example @@ -4,13 +4,3 @@ HOST_PORT=5173 CONTAINER_PORT=5173 SERVER_HOST_PORT=5000 SERVER_CONTAINER_PORT=5000 -DOCKER_NAME=mct_docker - -CURRENT_DIR=$(patsubst %/,%,$(dir $(realpath $(firstword $(MAKEFILE_LIST))))) -ROOT_DIR=$(CURRENT_DIR) -DOCKER_COMPOSE=docker compose -DOCKER_EXEC_TOOLS_APP=docker exec -it $(DOCKER_NAME) sh -NODE_INSTALL="npm i" -BUNDLE_RUN="npm run build" -CLEAN_RUN="rm -r dist/*" -SERVER_RUN="npm run development" From 2074e420a9612f3c27ad2a21368d9a7669448c64 Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Sat, 15 Nov 2025 12:29:09 -0300 Subject: [PATCH 23/31] Update project with types config and add new deps --- package-lock.json | 651 +++++++++++++++++++++++++++++----------------- package.json | 17 +- tsconfig.json | 14 + 3 files changed, 433 insertions(+), 249 deletions(-) create mode 100644 tsconfig.json diff --git a/package-lock.json b/package-lock.json index 74998e8..6b13d07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,14 @@ { "name": "dashboard", - "version": "1.5.1", + "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dashboard", - "version": "1.5.1", + "version": "1.6.1", "dependencies": { - "csvwritergen": "^0.0.4", - "vue-router": "^4.2.4" + "pinia": "^3.0.4" }, "devDependencies": { "chart.js": "^4.2.1", @@ -18,20 +17,41 @@ "csvwritergen": "^0.0.4", "dotenv": "^16.0.3", "express": "^4.18.2", - "naive-ui": "^2.34.4", + "naive-ui": "^2.43.1", "nodemon": "^2.0.22", "npm-run-all": "^4.1.5", + "typescript": "^5.9.3", "vite": "^4.2.0", "vue": "^3.2.47", - "vuex": "^4.1.0", - "vuex-map-fields": "^1.4.1" + "vue-router": "^4.5.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.21.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.4.tgz", - "integrity": "sha512-alVJj7k7zIxqBZ7BTRhz0IqJFxW1VJbm6N8JbcYhQ186df9ZBPbZBmWSqAMXwHGsCJdYks7z/voa3ibiS5bCIw==", - "dev": true, + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -39,32 +59,35 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/runtime": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.15.tgz", - "integrity": "sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==", - "dev": true, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", "dependencies": { - "regenerator-runtime": "^0.14.0" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@css-render/plugin-bem": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/@css-render/plugin-bem/-/plugin-bem-0.15.12.tgz", - "integrity": "sha512-Lq2jSOZn+wYQtsyaFj6QRz2EzAnd3iW5fZeHO1WSXQdVYwvwGX0ZiH3X2JQgtgYLT1yeGtrwrqJdNdMEUD2xTw==", + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", + "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==", "dev": true, + "license": "MIT", "peerDependencies": { - "css-render": "~0.15.12" + "css-render": "~0.15.14" } }, "node_modules/@css-render/vue3-ssr": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/@css-render/vue3-ssr/-/vue3-ssr-0.15.12.tgz", - "integrity": "sha512-AQLGhhaE0F+rwybRCkKUdzBdTEM/5PZBYy+fSYe1T9z9+yxMuV/k7ZRqa4M69X+EI1W8pa4kc9Iq2VjQkZx4rg==", + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz", + "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==", "dev": true, + "license": "MIT", "peerDependencies": { "vue": "^3.0.11" } @@ -73,7 +96,8 @@ "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@esbuild/android-arm": { "version": "0.17.15", @@ -427,11 +451,18 @@ "node": ">=12" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, "node_modules/@juggle/resize-observer": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/@kurkle/color": { "version": "0.3.2", @@ -440,74 +471,77 @@ "dev": true }, "node_modules/@types/katex": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.14.0.tgz", - "integrity": "sha512-+2FW2CcT0K3P+JMR8YG846bmDwplKUTsWgT2ENwdQ1UdVfRk3GQrh6Mi4sTopy30gI8Uau5CEqHTDZ6YvWIUPA==", - "dev": true + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", + "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", + "dev": true, + "license": "MIT" }, "node_modules/@types/lodash": { - "version": "4.14.197", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.197.tgz", - "integrity": "sha512-BMVOiWs0uNxHVlHBgzTIqJYmj+PgCo4euloGF+5m4okL3rEYzM2EEv78mw8zWSMM57dM7kVIgJ2QDvwHSoCI5g==", - "dev": true + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "dev": true, + "license": "MIT" }, "node_modules/@types/lodash-es": { - "version": "4.17.9", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.9.tgz", - "integrity": "sha512-ZTcmhiI3NNU7dEvWLZJkzG6ao49zOIjEgIE0RgV7wbPxU0f2xT3VSAHw2gmst8swH6V0YkLRGp4qPlX/6I90MQ==", + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/lodash": "*" } }, "node_modules/@vue/compiler-core": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.47.tgz", - "integrity": "sha512-p4D7FDnQb7+YJmO2iPEv0SQNeNzcbHdGByJDsT4lynf63AFkOTFN07HsiRSvjGo0QrxR/o3d0hUyNCUnBU2Tig==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.16.4", - "@vue/shared": "3.2.47", + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz", + "integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.24", + "entities": "^4.5.0", "estree-walker": "^2.0.2", - "source-map": "^0.6.1" + "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.47.tgz", - "integrity": "sha512-dBBnEHEPoftUiS03a4ggEig74J2YBZ2UIeyfpcRM2tavgMWo4bsEfgCGsu+uJIL/vax9S+JztH8NmQerUo7shQ==", - "dev": true, + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz", + "integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==", + "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.2.47", - "@vue/shared": "3.2.47" + "@vue/compiler-core": "3.5.24", + "@vue/shared": "3.5.24" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.47.tgz", - "integrity": "sha512-rog05W+2IFfxjMcFw10tM9+f7i/+FFpZJJ5XHX72NP9eC2uRD+42M3pYcQqDXVYoj74kHMSEdQ/WmCjt8JFksQ==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.16.4", - "@vue/compiler-core": "3.2.47", - "@vue/compiler-dom": "3.2.47", - "@vue/compiler-ssr": "3.2.47", - "@vue/reactivity-transform": "3.2.47", - "@vue/shared": "3.2.47", + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz", + "integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.24", + "@vue/compiler-dom": "3.5.24", + "@vue/compiler-ssr": "3.5.24", + "@vue/shared": "3.5.24", "estree-walker": "^2.0.2", - "magic-string": "^0.25.7", - "postcss": "^8.1.10", - "source-map": "^0.6.1" + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.47.tgz", - "integrity": "sha512-wVXC+gszhulcMD8wpxMsqSOpvDZ6xKXSVWkf50Guf/S+28hTAXPDYRTbLQ3EDkOP5Xz/+SY37YiwDquKbJOgZw==", - "dev": true, + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz", + "integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==", + "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.2.47", - "@vue/shared": "3.2.47" + "@vue/compiler-dom": "3.5.24", + "@vue/shared": "3.5.24" } }, "node_modules/@vue/devtools-api": { @@ -516,73 +550,79 @@ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", "dev": true }, - "node_modules/@vue/reactivity": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.47.tgz", - "integrity": "sha512-7khqQ/75oyyg+N/e+iwV6lpy1f5wq759NdlS1fpAhFXa8VeAIKGgk2E/C4VF59lx5b+Ezs5fpp/5WsRYXQiKxQ==", - "dev": true, + "node_modules/@vue/devtools-kit": { + "version": "7.7.8", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.8.tgz", + "integrity": "sha512-4Y8op+AoxOJhB9fpcEF6d5vcJXWKgHxC3B0ytUB8zz15KbP9g9WgVzral05xluxi2fOeAy6t140rdQ943GcLRQ==", + "license": "MIT", "dependencies": { - "@vue/shared": "3.2.47" + "@vue/devtools-shared": "^7.7.8", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" } }, - "node_modules/@vue/reactivity-transform": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.47.tgz", - "integrity": "sha512-m8lGXw8rdnPVVIdIFhf0LeQ/ixyHkH5plYuS83yop5n7ggVJU+z5v0zecwEnX7fa7HNLBhh2qngJJkxpwEEmYA==", - "dev": true, + "node_modules/@vue/devtools-shared": { + "version": "7.7.8", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.8.tgz", + "integrity": "sha512-XHpO3jC5nOgYr40M9p8Z4mmKfTvUxKyRcUnpBAYg11pE78eaRFBKb0kG5yKLroMuJeeNH9LWmKp2zMU5LUc7CA==", + "license": "MIT", "dependencies": { - "@babel/parser": "^7.16.4", - "@vue/compiler-core": "3.2.47", - "@vue/shared": "3.2.47", - "estree-walker": "^2.0.2", - "magic-string": "^0.25.7" + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz", + "integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.24" } }, "node_modules/@vue/runtime-core": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.2.47.tgz", - "integrity": "sha512-RZxbLQIRB/K0ev0K9FXhNbBzT32H9iRtYbaXb0ZIz2usLms/D55dJR2t6cIEUn6vyhS3ALNvNthI+Q95C+NOpA==", - "dev": true, + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz", + "integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==", + "license": "MIT", "dependencies": { - "@vue/reactivity": "3.2.47", - "@vue/shared": "3.2.47" + "@vue/reactivity": "3.5.24", + "@vue/shared": "3.5.24" } }, "node_modules/@vue/runtime-dom": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.2.47.tgz", - "integrity": "sha512-ArXrFTjS6TsDei4qwNvgrdmHtD930KgSKGhS5M+j8QxXrDJYLqYw4RRcDy1bz1m1wMmb6j+zGLifdVHtkXA7gA==", - "dev": true, + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz", + "integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==", + "license": "MIT", "dependencies": { - "@vue/runtime-core": "3.2.47", - "@vue/shared": "3.2.47", - "csstype": "^2.6.8" + "@vue/reactivity": "3.5.24", + "@vue/runtime-core": "3.5.24", + "@vue/shared": "3.5.24", + "csstype": "^3.1.3" } }, - "node_modules/@vue/runtime-dom/node_modules/csstype": { - "version": "2.6.21", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", - "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==", - "dev": true - }, "node_modules/@vue/server-renderer": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.2.47.tgz", - "integrity": "sha512-dN9gc1i8EvmP9RCzvneONXsKfBRgqFeFZLurmHOveL7oH6HiFXJw5OGu294n1nHc/HMgTy6LulU/tv5/A7f/LA==", - "dev": true, + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz", + "integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==", + "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.2.47", - "@vue/shared": "3.2.47" + "@vue/compiler-ssr": "3.5.24", + "@vue/shared": "3.5.24" }, "peerDependencies": { - "vue": "3.2.47" + "vue": "3.5.24" } }, "node_modules/@vue/shared": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.47.tgz", - "integrity": "sha512-BHGyyGN3Q97EZx0taMQ+OLNuZcW3d37ZEVmEAyeoA9ERdGvm9Irc/0Fua8SNyOtV1w6BS4q25wbMzJujO9HIfQ==", - "dev": true + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz", + "integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==", + "license": "MIT" }, "node_modules/abbrev": { "version": "1.1.1", @@ -651,7 +691,8 @@ "version": "4.2.5", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/available-typed-arrays": { "version": "1.0.5", @@ -680,6 +721,15 @@ "node": ">=8" } }, + "node_modules/birpc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.8.0.tgz", + "integrity": "sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", @@ -867,6 +917,21 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "dev": true }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -897,20 +962,28 @@ } }, "node_modules/css-render": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.12.tgz", - "integrity": "sha512-eWzS66patiGkTTik+ipO9qNGZ+uNuGyTmnz6/+EJIiFg8+3yZRpnMwgFo8YdXhQRsiePzehnusrxVvugNjXzbw==", + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz", + "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", "dev": true, + "license": "MIT", "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" } }, - "node_modules/csstype": { + "node_modules/css-render/node_modules/csstype": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" }, "node_modules/csvwritergen": { "version": "0.0.4", @@ -919,28 +992,24 @@ "dev": true }, "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "dev": true, - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, + "license": "MIT", "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, "node_modules/date-fns-tz": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-1.3.8.tgz", - "integrity": "sha512-qwNXUFtMHTTU6CFSFjoJ80W8Fzzp24LntbjFFBgL/faqds4e5mo9mftoRLgr3Vi1trISsg4awSpYVsOQCRnapQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", "dev": true, + "license": "MIT", "peerDependencies": { - "date-fns": ">=2.0.0" + "date-fns": "^3.0.0 || ^4.0.0" } }, "node_modules/debug": { @@ -1011,6 +1080,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -1155,7 +1236,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true + "license": "MIT" }, "node_modules/etag": { "version": "1.8.1", @@ -1170,7 +1251,8 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/evtd/-/evtd-0.2.4.tgz", "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/express": { "version": "4.18.2", @@ -1475,14 +1557,21 @@ } }, "node_modules/highlight.js": { - "version": "11.8.0", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", - "integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==", + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=12.0.0" } }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -1797,6 +1886,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1828,21 +1929,23 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash-es": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/magic-string": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", - "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", - "dev": true, + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", "dependencies": { - "sourcemap-codec": "^1.4.8" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/media-typer": { @@ -1923,6 +2026,12 @@ "node": "*" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -1930,45 +2039,47 @@ "dev": true }, "node_modules/naive-ui": { - "version": "2.34.4", - "resolved": "https://registry.npmjs.org/naive-ui/-/naive-ui-2.34.4.tgz", - "integrity": "sha512-aPG8PDfhSzIzn/jSC9y3Jb3Pe2wHJ7F0cFV1EWlbImSrZECeUmoc+fIcOSWbizoztkKfaUAeKwYdMl09MKkj1g==", - "dev": true, - "dependencies": { - "@css-render/plugin-bem": "^0.15.10", - "@css-render/vue3-ssr": "^0.15.10", - "@types/katex": "^0.14.0", - "@types/lodash": "^4.14.181", - "@types/lodash-es": "^4.17.6", - "async-validator": "^4.0.7", - "css-render": "^0.15.10", - "date-fns": "^2.28.0", - "date-fns-tz": "^1.3.3", + "version": "2.43.1", + "resolved": "https://registry.npmjs.org/naive-ui/-/naive-ui-2.43.1.tgz", + "integrity": "sha512-w52W0mOhdOGt4uucFSZmP0DI44PCsFyuxeLSs9aoUThfIuxms90MYjv46Qrr7xprjyJRw5RU6vYpCx4o9ind3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@css-render/plugin-bem": "^0.15.14", + "@css-render/vue3-ssr": "^0.15.14", + "@types/katex": "^0.16.2", + "@types/lodash": "^4.14.198", + "@types/lodash-es": "^4.17.9", + "async-validator": "^4.2.5", + "css-render": "^0.15.14", + "csstype": "^3.1.3", + "date-fns": "^3.6.0", + "date-fns-tz": "^3.1.3", "evtd": "^0.2.4", - "highlight.js": "^11.5.0", + "highlight.js": "^11.8.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", - "seemly": "^0.3.6", + "seemly": "^0.3.8", "treemate": "^0.3.11", "vdirs": "^0.1.8", "vooks": "^0.2.12", - "vueuc": "^0.4.51" + "vueuc": "^0.4.65" }, "peerDependencies": { "vue": "^3.0.0" } }, "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", - "dev": true, + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -2207,11 +2318,17 @@ "node": ">=4" } }, - "node_modules/picocolors": { + "node_modules/perfect-debounce": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -2246,11 +2363,40 @@ "node": ">=4" } }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/@vue/devtools-api": { + "version": "7.7.8", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.8.tgz", + "integrity": "sha512-BtFcAmDbtXGwurWUFf8ogIbgZyR+rcVES1TSNEI8Em80fD8Anu+qTRN1Fc3J6vdRHlVM3fzPV1qIo+B4AiqGzw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.8" + } + }, "node_modules/postcss": { - "version": "8.4.21", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", - "dev": true, + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -2259,12 +2405,17 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.4", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -2354,12 +2505,6 @@ "node": ">=8.10.0" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", - "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==", - "dev": true - }, "node_modules/regexp.prototype.flags": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", @@ -2394,6 +2539,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rollup": { "version": "3.20.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.20.2.tgz", @@ -2451,10 +2602,11 @@ "dev": true }, "node_modules/seemly": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/seemly/-/seemly-0.3.6.tgz", - "integrity": "sha512-lEV5VB8BUKTo/AfktXJcy+JeXns26ylbMkIUco8CYREsQijuz4mrXres2Q+vMLdwkuLxJdIPQ8IlCIxLYm71Yw==", - "dev": true + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/seemly/-/seemly-0.3.10.tgz", + "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==", + "dev": true, + "license": "MIT" }, "node_modules/semver": { "version": "5.7.1", @@ -2581,31 +2733,15 @@ "semver": "bin/semver.js" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true, + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "deprecated": "Please use @jridgewell/sourcemap-codec instead", - "dev": true - }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -2638,6 +2774,15 @@ "integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==", "dev": true }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -2718,6 +2863,18 @@ "node": ">=4" } }, + "node_modules/superjson": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.5.tgz", + "integrity": "sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -2779,7 +2936,8 @@ "version": "0.3.11", "resolved": "https://registry.npmjs.org/treemate/-/treemate-0.3.11.tgz", "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/type-is": { "version": "1.6.18", @@ -2808,6 +2966,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -2871,6 +3043,7 @@ "resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz", "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==", "dev": true, + "license": "MIT", "dependencies": { "evtd": "^0.2.2" }, @@ -2932,6 +3105,7 @@ "resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz", "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==", "dev": true, + "license": "MIT", "dependencies": { "evtd": "^0.2.2" }, @@ -2940,16 +3114,24 @@ } }, "node_modules/vue": { - "version": "3.2.47", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.2.47.tgz", - "integrity": "sha512-60188y/9Dc9WVrAZeUVSDxRQOZ+z+y5nO2ts9jWXSTkMvayiWxCWOWtBQoYjLeccfXkiiPZWAHcV+WTPhkqJHQ==", - "dev": true, + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz", + "integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==", + "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.2.47", - "@vue/compiler-sfc": "3.2.47", - "@vue/runtime-dom": "3.2.47", - "@vue/server-renderer": "3.2.47", - "@vue/shared": "3.2.47" + "@vue/compiler-dom": "3.5.24", + "@vue/compiler-sfc": "3.5.24", + "@vue/runtime-dom": "3.5.24", + "@vue/server-renderer": "3.5.24", + "@vue/shared": "3.5.24" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/vue-router": { @@ -2968,10 +3150,11 @@ } }, "node_modules/vueuc": { - "version": "0.4.51", - "resolved": "https://registry.npmjs.org/vueuc/-/vueuc-0.4.51.tgz", - "integrity": "sha512-pLiMChM4f+W8czlIClGvGBYo656lc2Y0/mXFSCydcSmnCR1izlKPGMgiYBGjbY9FDkFG8a2HEVz7t0DNzBWbDw==", + "version": "0.4.65", + "resolved": "https://registry.npmjs.org/vueuc/-/vueuc-0.4.65.tgz", + "integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==", "dev": true, + "license": "MIT", "dependencies": { "@css-render/vue3-ssr": "^0.15.10", "@juggle/resize-observer": "^3.3.1", @@ -2985,24 +3168,6 @@ "vue": "^3.0.11" } }, - "node_modules/vuex": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/vuex/-/vuex-4.1.0.tgz", - "integrity": "sha512-hmV6UerDrPcgbSy9ORAtNXDr9M4wlNP4pEFKye4ujJF8oqgFFuxDCdOLS3eNoRTtq5O3hoBDh9Doj1bQMYHRbQ==", - "dev": true, - "dependencies": { - "@vue/devtools-api": "^6.0.0-beta.11" - }, - "peerDependencies": { - "vue": "^3.2.0" - } - }, - "node_modules/vuex-map-fields": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/vuex-map-fields/-/vuex-map-fields-1.4.1.tgz", - "integrity": "sha512-jvIcpvoIPqwvJCOfRkPU9Rj0EbjWuk7GlNC5LXU9mCXVGZph6bWGHZssnoUzpLMxJtXQEHoVyZkKf7YQV+/bnQ==", - "dev": true - }, "node_modules/which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", diff --git a/package.json b/package.json index 40dbb74..4d8f503 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dashboard", "private": true, - "version": "1.6.0", + "version": "1.6.1", "type": "module", "main": "dist/dashboard.js", "scripts": { @@ -9,9 +9,12 @@ "dev": "vite --host", "start": "nodemon server.js", "build": "vite build", + "bundle": "run-p types build", "preview": "vite preview", "test:unit": "vitest --environment jsdom --root src/ run", - "test-watch:unit": "vitest --environment jsdom --root src/ watch" + "test-watch:unit": "vitest --environment jsdom --root src/ watch", + "types": "tsc", + "types-watch": "tsc --watch" }, "devDependencies": { "chart.js": "^4.2.1", @@ -20,13 +23,15 @@ "csvwritergen": "^0.0.4", "dotenv": "^16.0.3", "express": "^4.18.2", - "naive-ui": "^2.34.4", + "naive-ui": "^2.43.1", "nodemon": "^2.0.22", "npm-run-all": "^4.1.5", + "typescript": "^5.9.3", "vite": "^4.2.0", "vue": "^3.2.47", - "vue-router": "^4.5.0", - "vuex": "^4.1.0", - "vuex-map-fields": "^1.4.1" + "vue-router": "^4.5.0" + }, + "dependencies": { + "pinia": "^3.0.4" } } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a66be93 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "strict": true, + "skipLibCheck": true + }, + "exclude": ["node_modules", "dist"], + "include": ["src/**/*"] +} From e6a9e5e1ef4e9983107246c121512b9ebcf9af46 Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Sat, 15 Nov 2025 16:58:08 -0300 Subject: [PATCH 24/31] Update vite config and ts to alias @ --- tsconfig.json | 5 ++++- vite.config.js | 53 ++++++++++++++++++++++++++++---------------------- 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index a66be93..0223709 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,10 @@ "emitDeclarationOnly": true, "outDir": "dist", "strict": true, - "skipLibCheck": true + "skipLibCheck": true, + "paths": { + "@/*": ["./src/*"] + } }, "exclude": ["node_modules", "dist"], "include": ["src/**/*"] diff --git a/vite.config.js b/vite.config.js index 601786b..d95ae2c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,31 +1,38 @@ import { defineConfig } from "vite"; import path from "path"; import { version } from "./package.json"; +import { fileURLToPath, URL } from "node:url"; -export default defineConfig({ - resolve: { - alias: { - 'chartjs': 'chart.js', - } - }, - build: { - minify: true, - lib: { - entry: path.resolve(__dirname, "src/main.js"), - name: "MCT", - fileName: () => `mct-${version}.js`, - - formats: ['umd'] +export default defineConfig(({ command, mode }) => { + const isBuild = command === 'build'; + return { + resolve: { + alias: { + 'chartjs': 'chart.js', + 'vue': 'vue/dist/vue.esm-bundler.js', + "@": fileURLToPath(new URL("./src", import.meta.url)), + } }, - rollupOptions: { - output: { - assetFileNames: `mct-${version}.[ext]`, + build: { + minify: true, + lib: { + entry: path.resolve(__dirname, "src/main.js"), + name: "MCT", + fileName: () => `mct-${version}.js`, + + formats: ['umd'] + }, + rollupOptions: { + output: { + assetFileNames: `mct-${version}.[ext]`, + }, }, }, - }, - define: { - 'process.env.NODE_ENV': '"production"', - '__VUE_OPTIONS_API__': true, - '__VUE_PROD_DEVTOOLS__': true, - }, + define: { + 'process.env.NODE_ENV': isBuild ? '"production"' : '"development"', + '__VUE_OPTIONS_API__': !isBuild, + '__VUE_PROD_DEVTOOLS__': !isBuild, + '__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': !isBuild + } + } }); From a91fa083db0b67cbd2120a8c69b01996136e9d68 Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Sat, 15 Nov 2025 17:05:45 -0300 Subject: [PATCH 25/31] Update deps versions and setup --- package-lock.json | 17 ++++++++--------- package.json | 7 +++---- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6b13d07..453553a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "dashboard", + "name": "mct", "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "dashboard", + "name": "mct", "version": "1.6.1", "dependencies": { "pinia": "^3.0.4" @@ -22,8 +22,8 @@ "npm-run-all": "^4.1.5", "typescript": "^5.9.3", "vite": "^4.2.0", - "vue": "^3.2.47", - "vue-router": "^4.5.0" + "vue": "^3.5.24", + "vue-router": "^4.6.3" } }, "node_modules/@babel/helper-string-parser": { @@ -3117,7 +3117,6 @@ "version": "3.5.24", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz", "integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==", - "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.5.24", "@vue/compiler-sfc": "3.5.24", @@ -3135,9 +3134,9 @@ } }, "node_modules/vue-router": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.0.tgz", - "integrity": "sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w==", + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz", + "integrity": "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==", "dev": true, "dependencies": { "@vue/devtools-api": "^6.6.4" @@ -3146,7 +3145,7 @@ "url": "https://github.com/sponsors/posva" }, "peerDependencies": { - "vue": "^3.2.0" + "vue": "^3.5.0" } }, "node_modules/vueuc": { diff --git a/package.json b/package.json index 4d8f503..fd777aa 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,8 @@ { - "name": "dashboard", + "name": "mct", "private": true, "version": "1.6.1", "type": "module", - "main": "dist/dashboard.js", "scripts": { "development": "run-p start dev", "dev": "vite --host", @@ -28,8 +27,8 @@ "npm-run-all": "^4.1.5", "typescript": "^5.9.3", "vite": "^4.2.0", - "vue": "^3.2.47", - "vue-router": "^4.5.0" + "vue": "^3.5.24", + "vue-router": "^4.6.3" }, "dependencies": { "pinia": "^3.0.4" From 54211c3a18a307b0f0cfd74382f373cd412a4cbf Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Sat, 15 Nov 2025 18:32:33 -0300 Subject: [PATCH 26/31] Add prettier to project --- .prettierrc.js | 12 ++++++++++++ Makefile | 5 ++++- package-lock.json | 16 ++++++++++++++++ package.json | 4 +++- tsconfig.json | 3 ++- 5 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 .prettierrc.js diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..f50b949 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,12 @@ +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + trailingComma: "es5", + tabWidth: 4, + semi: false, + singleQuote: true, +}; + +export default config; diff --git a/Makefile b/Makefile index 9dcda61..fc43cd3 100644 --- a/Makefile +++ b/Makefile @@ -37,10 +37,13 @@ restart: stop start dev ## Stop and restart container types: ## Run type check and generator docker compose exec mct_web npm run types +prettier: ## Run prettier the opinionated code formatter in code + docker compose exec mct_web npm run prettier + types-watch: ## Run type check and generator docker compose exec mct_web npm run types-watch clear: stop ./compose.yml ## Stop and remove container and orphans docker compose down -v --remove-orphans -.PHONY: bash build clean help logs start stop types types-watch +.PHONY: bash build clean help logs start stop types types-watch prettier diff --git a/package-lock.json b/package-lock.json index 453553a..c5b77d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "naive-ui": "^2.43.1", "nodemon": "^2.0.22", "npm-run-all": "^4.1.5", + "prettier": "3.6.2", "typescript": "^5.9.3", "vite": "^4.2.0", "vue": "^3.5.24", @@ -2421,6 +2422,21 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/package.json b/package.json index fd777aa..f561712 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "test:unit": "vitest --environment jsdom --root src/ run", "test-watch:unit": "vitest --environment jsdom --root src/ watch", "types": "tsc", - "types-watch": "tsc --watch" + "types-watch": "tsc --watch", + "prettier": "prettier src/ --write" }, "devDependencies": { "chart.js": "^4.2.1", @@ -25,6 +26,7 @@ "naive-ui": "^2.43.1", "nodemon": "^2.0.22", "npm-run-all": "^4.1.5", + "prettier": "3.6.2", "typescript": "^5.9.3", "vite": "^4.2.0", "vue": "^3.5.24", diff --git a/tsconfig.json b/tsconfig.json index 0223709..002cdd0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,8 @@ "skipLibCheck": true, "paths": { "@/*": ["./src/*"] - } + }, + "lib": ["ES2020", "DOM"] }, "exclude": ["node_modules", "dist"], "include": ["src/**/*"] From faf0be502ca590d545ccb93e47bff04321973dfd Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Mon, 17 Nov 2025 14:53:49 -0300 Subject: [PATCH 27/31] Update code with new format --- images.d.ts | 13 + index.html | 45 +- oldSrc/components/main-card.js | 140 --- oldSrc/components/map/map.js | 2 + oldSrc/components/map/year-slider.js | 10 +- oldSrc/components/sub-select.js | 2 + oldSrc/components/table.js | 12 +- oldSrc/data-fetcher.js | 1 + oldSrc/store/modules/content/actions.js | 2 +- package.json | 3 +- src/assets/css/base.css | 19 + src/assets/css/components/collapsable.css | 26 + src/assets/css/components/container.css | 58 ++ .../css/components/filter-suggestion.css | 65 ++ src/assets/css/components/label.css | 10 + src/assets/css/components/main-content.css | 27 + src/assets/css/components/main-footer.css | 75 ++ src/assets/css/components/main-header.css | 90 ++ src/assets/css/components/map.css | 8 + src/assets/css/components/modal.css | 40 + src/assets/css/components/select.css | 142 +++ src/assets/css/components/slider.css | 64 ++ src/assets/css/components/tab.css | 70 ++ src/assets/css/components/table.css | 99 ++ src/assets/css/map-chart-table-legend.css | 79 ++ src/assets/css/map-chart-table.css | 216 +++++ src/assets/css/style.css | 110 +++ src/assets/images/abandono.svg | 169 ++++ src/assets/images/cc.png | Bin 0 -> 695 bytes src/assets/images/cobertura.svg | 199 ++++ src/assets/images/hom_geo.svg | 217 +++++ src/assets/images/hom_vac.svg | 244 +++++ src/assets/images/legend.png | Bin 0 -> 6905 bytes src/assets/images/logo-vacinabr.svg | 21 + src/assets/images/meta.svg | 248 +++++ src/assets/images/ri-alert-line.svg | 10 + src/assets/images/sbim.png | Bin 0 -> 12703 bytes src/canvas-download.js | 249 +++++ src/common.js | 70 ++ src/components/config.js | 59 ++ src/components/main/card-components/chart.js | 480 +++++++++ .../main/card-components/filter-suggestion.js | 118 +++ .../main/card-components/map/index.js | 25 + .../main/card-components/map/map-range.js | 397 ++++++++ .../main/card-components/map/map.js | 57 ++ .../main/card-components/map/year-slider.js | 245 +++++ .../main/card-components/sub-buttons.js | 557 +++++++++++ .../main/card-components/sub-select.js | 691 +++++++++++++ src/components/main/card-components/table.js | 119 +++ src/components/main/card.js | 127 +++ src/components/main/index.js | 158 +++ src/components/main/modal/index.js | 51 + src/components/main/modal/modal-caller.js | 47 + src/components/main/modal/modal-generic.js | 76 ++ .../main/modal/modal-genetic-with-tabs.js | 63 ++ src/data-fetcher.js | 150 +++ src/icons.js | 65 ++ src/main.js | 122 +++ src/map-chart.js | 759 +++++++++++++++ src/router/index.js | 30 + src/stores/chart.js | 200 ++++ src/stores/content.js | 916 ++++++++++++++++++ src/stores/index.js | 6 + src/stores/map.js | 268 +++++ src/stores/message.js | 39 + src/stores/modal.js | 42 + src/stores/table.js | 94 ++ src/utils.js | 696 +++++++++++++ tsconfig.json | 8 +- 69 files changed, 9366 insertions(+), 154 deletions(-) create mode 100644 images.d.ts create mode 100644 src/assets/css/base.css create mode 100644 src/assets/css/components/collapsable.css create mode 100644 src/assets/css/components/container.css create mode 100644 src/assets/css/components/filter-suggestion.css create mode 100644 src/assets/css/components/label.css create mode 100644 src/assets/css/components/main-content.css create mode 100644 src/assets/css/components/main-footer.css create mode 100644 src/assets/css/components/main-header.css create mode 100644 src/assets/css/components/map.css create mode 100644 src/assets/css/components/modal.css create mode 100644 src/assets/css/components/select.css create mode 100644 src/assets/css/components/slider.css create mode 100644 src/assets/css/components/tab.css create mode 100644 src/assets/css/components/table.css create mode 100644 src/assets/css/map-chart-table-legend.css create mode 100644 src/assets/css/map-chart-table.css create mode 100644 src/assets/css/style.css create mode 100644 src/assets/images/abandono.svg create mode 100644 src/assets/images/cc.png create mode 100644 src/assets/images/cobertura.svg create mode 100644 src/assets/images/hom_geo.svg create mode 100644 src/assets/images/hom_vac.svg create mode 100644 src/assets/images/legend.png create mode 100644 src/assets/images/logo-vacinabr.svg create mode 100644 src/assets/images/meta.svg create mode 100644 src/assets/images/ri-alert-line.svg create mode 100644 src/assets/images/sbim.png create mode 100644 src/canvas-download.js create mode 100644 src/common.js create mode 100644 src/components/config.js create mode 100644 src/components/main/card-components/chart.js create mode 100644 src/components/main/card-components/filter-suggestion.js create mode 100644 src/components/main/card-components/map/index.js create mode 100644 src/components/main/card-components/map/map-range.js create mode 100644 src/components/main/card-components/map/map.js create mode 100644 src/components/main/card-components/map/year-slider.js create mode 100644 src/components/main/card-components/sub-buttons.js create mode 100644 src/components/main/card-components/sub-select.js create mode 100644 src/components/main/card-components/table.js create mode 100644 src/components/main/card.js create mode 100644 src/components/main/index.js create mode 100644 src/components/main/modal/index.js create mode 100644 src/components/main/modal/modal-caller.js create mode 100644 src/components/main/modal/modal-generic.js create mode 100644 src/components/main/modal/modal-genetic-with-tabs.js create mode 100644 src/data-fetcher.js create mode 100644 src/icons.js create mode 100644 src/main.js create mode 100644 src/map-chart.js create mode 100644 src/router/index.js create mode 100644 src/stores/chart.js create mode 100644 src/stores/content.js create mode 100644 src/stores/index.js create mode 100644 src/stores/map.js create mode 100644 src/stores/message.js create mode 100644 src/stores/modal.js create mode 100644 src/stores/table.js create mode 100644 src/utils.js diff --git a/images.d.ts b/images.d.ts new file mode 100644 index 0000000..9398a2b --- /dev/null +++ b/images.d.ts @@ -0,0 +1,13 @@ +declare module "*.svg" { + const content: string; + export default content; +} + +declare module '*.png' { + const value: string; + export default value; +} + +declare module '*.jpg'; +declare module '*.jpeg'; +declare module '*.svg'; diff --git a/index.html b/index.html index 27c3ef0..04bde35 100644 --- a/index.html +++ b/index.html @@ -4,14 +4,53 @@ + Map Chart Table + + + + +
- + + + + + + diff --git a/oldSrc/components/main-card.js b/oldSrc/components/main-card.js index e7d610e..86b2115 100644 --- a/oldSrc/components/main-card.js +++ b/oldSrc/components/main-card.js @@ -83,146 +83,6 @@ export const mainCard = { mapTooltip.value = tooltip; }; - const URLquery = { ...route.query }; - const removeQueryFromRouter = (key) => { - delete URLquery[key]; - message.warning('URL contém valor inválido para filtragem') - router.replace({ query: URLquery }); - } - - const setStateFromUrl = () => { - const formState = store.state.content.form - const routeArgs = { ...route.query }; - const routerResult = {}; - const routerResultTabs = {}; - - if (!Object.keys(routeArgs).length) { - return; - } - - for (const [key, value] of Object.entries(routeArgs)) { - if (key === "sickImmunizer") { - if (value.includes(",")) { - const values = value.split(",") - const sicks = formState["sicks"].map(el => el.value) - const immunizers = formState["immunizers"].map(el => el.value) - if ( - values.every(val => sicks.includes(val)) || - values.every(val => immunizers.includes(val)) - ) { - routerResult[key] = values; - } else { - removeQueryFromRouter(key); - } - } else if ( - formState["sicks"].some(el => el.value === value) || - formState["immunizers"].some(el => el.value === value) - ) { - routerResult[key] = value; - } else { - removeQueryFromRouter(key); - } - } else if (key === "city") { - // TODO: define if cities will be in URL state - // const values = value.split(",") - // const cities = formState["cities"].map(el => el.value) - // if (values.every(val => cities.includes(val))) { - // routerResult[key] = values; - // } else { - // removeQueryFromRouter(key); - // } - } else if (key === "local") { - const values = value.split(",") - const locals = formState["locals"].map(el => el.value) - if (values.every(val => locals.includes(val))) { - routerResult[key] = values; - } else { - removeQueryFromRouter(key); - } - } else if (key === "granularity") { - formState["granularities"].some(el => el.value === value) ? - routerResult[key] = value : removeQueryFromRouter(key); - } else if (key === "dose") { - formState["doses"].some(el => el.value === value) ? - routerResult[key] = value : removeQueryFromRouter(key); - } else if (key === "type") { - formState["types"].some(el => el.value === value) ? - routerResult[key] = value : removeQueryFromRouter(key); - } else if (key === "tab") { - ["map", "chart", "table"].some(el => el === value) ? - routerResultTabs[key] = value : removeQueryFromRouter(key); - } else if (key === "tabBy") { - ["immunizers", "sicks"].some(el => el === value) ? - routerResultTabs[key] = value : removeQueryFromRouter(key); - } else if (["periodStart", "periodEnd"].includes(key)) { - const resultValue = Number(value) - formState["years"].some(el => el.value === resultValue) ? - routerResult[key] = resultValue : removeQueryFromRouter(key); - } else if (key === "period") { - routerResult[key] = Number(value); - } else if (value.includes(",")) { - routerResult[key] = value.split(","); - } else { - routerResult[key] = value ?? null; - } - } - - store.commit("content/UPDATE_FROM_URL", { - tab: routerResultTabs?.tab ? routerResultTabs.tab : "map", - tabBy: routerResultTabs?.tabBy ? routerResultTabs.tabBy : "sicks", - form: { ...routerResult }, - }); - }; - - const setUrlFromState = () => { - const routeArgs = { ...route.query }; - let stateResult = formatToApi({ - form: { ...store.state.content.form }, - tab: store.state.content.tab !== "map" ? store.state.content.tab : undefined, - tabBy: store.state.content.tabBy !== "sicks" ? store.state.content.tabBy : undefined, - }); - if (Array.isArray(stateResult.sickImmunizer) && stateResult.sickImmunizer.length) { - stateResult.sickImmunizer = [...stateResult?.sickImmunizer].join(","); - } - if (Array.isArray(stateResult.local) && stateResult.local.length) { - stateResult.local = [...stateResult?.local].join(","); - } - // TODO: define if cities will be in URL state - // if (Array.isArray(stateResult.city) && stateResult.city.length) { - // stateResult.city = [...stateResult?.city].join(","); - // } - delete stateResult.city - - if (!JSON.stringify(routeArgs) == JSON.stringify(stateResult)) { - return; - } - - return router.replace({ query: stateResult }); - } - - watch(() => { - const form = store.state.content.form; - return [ - form.dose, - form.granularity, - form.granularity, - form.local, - form.period, - form.periodEnd, - form.periodStart, - form.sickImmunizer, - form.type, - // TODO: define if cities will be in URL state - // form.city, - store.state.content.tab, - store.state.content.tabBy - ] - }, - async () => { - setUrlFromState(); - } - ) - onMounted(async () => { getWindowWidth(); await store.dispatch("content/updateFormSelect"); diff --git a/oldSrc/components/map/map.js b/oldSrc/components/map/map.js index 967856e..19bcdc4 100644 --- a/oldSrc/components/map/map.js +++ b/oldSrc/components/map/map.js @@ -16,8 +16,10 @@ export const map = { const yearMapElement = ref(null); const mapChart = ref(null); const timeOutId = ref(null); + const store = useStore(); const loading = computed(computedVar({ store, mutation: "content/UPDATE_LOADING", field: "loading" })); + const datasetStates = ref(null); const datasetCities = ref(null); const granularity = computed(() => store.state.content.form.granularity); diff --git a/oldSrc/components/map/year-slider.js b/oldSrc/components/map/year-slider.js index 2878541..eebbdd2 100644 --- a/oldSrc/components/map/year-slider.js +++ b/oldSrc/components/map/year-slider.js @@ -20,6 +20,7 @@ export const yearSlider = { const showTooltip = ref(false); const mapPlaying = computed(computedVar({ store, mutation: "content/UPDATE_YEAR_SLIDER_ANIMATION", field: "yearSlideAnimation" })); const stopPlayMap = ref(false); + const setSliderValue = (period) => { const form = store.state.content.form; showSlider.value = form.periodStart && form.periodEnd ? true : false; @@ -33,18 +34,19 @@ export const yearSlider = { const min = computed(() => setSliderValue(store.state.content.form.periodStart)); const valueMandatoryLabels = ref(null); + const valueMandatory = computed(() => { const tabBy = store.state.content.tabBy; if (tabBy !== "immunizers") { return } - const sickImmunizer = store.state.content.form.sickImmunizer; - const dose = store.state.content.form.dose ? store.state.content.form.dose : "1ª dose"; - const mandatoryVaccineYears = store.state.content.mandatoryVaccineYears; + const sickImmunizer = form.value.sickImmunizer; + const dose = form.value.dose ? form.value.dose : "1ª dose"; + const mandatoryVaccineYears = mandatoryVaccineYears.value; if (mandatoryVaccineYears) { - const result = mandatoryVaccineYears.find(el => el[0] === sickImmunizer && + const result = mandatoryVaccineYears.find(/** @type{string[]} **/ el => el[0] === sickImmunizer && (el[1] === dose || el[1] === "Dose única" && dose === "1ª dose") ); if (result) { diff --git a/oldSrc/components/sub-select.js b/oldSrc/components/sub-select.js index 6f015f6..43f42cd 100755 --- a/oldSrc/components/sub-select.js +++ b/oldSrc/components/sub-select.js @@ -29,6 +29,7 @@ export const subSelect = { const sickTemp = ref(null); const localTemp = ref(null); const citiesTemp = ref([]); + const disableLocalSelect = computed(() => store.getters[`content/disableLocalSelect`]); const sick = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "sickImmunizer" })); const sicks = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "sicks" })); @@ -47,6 +48,7 @@ export const subSelect = { const years = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "years" })) const city = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "city" })) const cities = computed(computedVar({ store, base: "form", mutation: "content/UPDATE_FORM", field: "cities" })) + const selectRefsMap = reactive({}); const resizeObserver = ref(null); const isLoadingCities = ref(false); diff --git a/oldSrc/components/table.js b/oldSrc/components/table.js index 4b9c9b7..094fe99 100644 --- a/oldSrc/components/table.js +++ b/oldSrc/components/table.js @@ -1,4 +1,4 @@ -import { ref, onMounted, computed, watch } from "vue/dist/vue.esm-bundler"; +import { ref, onMounted, onBeforeUnomount, computed, watch } from "vue/dist/vue.esm-bundler"; import { NButton, NDataTable, NSelect, NEmpty } from "naive-ui"; import { computedVar, formatToTable } from "../utils"; import { useStore } from 'vuex'; @@ -17,14 +17,16 @@ export const table = { }, setup() { const store = useStore(); + const rows = ref([]); const columns = ref([]); const page = ref(1); const pageCount = ref(0); const pageTotalItems = ref(10); - const loading = computed(computedVar({ store, mutation: "content/UPDATE_LOADING", field: "loading" })); const sorter = ref(null); + const loading = computed(computedVar({ store, mutation: "content/UPDATE_LOADING", field: "loading" })); + const pagination = computed(() => ({ page: page.value, pageCount: pageCount.value, @@ -74,7 +76,11 @@ export const table = { onMounted(async () => { updateTableContent() - }); + }) + + onBeforeUnomount(() => { + tableStore.resetState() + }) watch( () => { diff --git a/oldSrc/data-fetcher.js b/oldSrc/data-fetcher.js index b034f66..7e6b159 100644 --- a/oldSrc/data-fetcher.js +++ b/oldSrc/data-fetcher.js @@ -69,6 +69,7 @@ export class DataFetcher { } async requestSettingApiEndPoint(endPoint, apiEndpoint, signal) { + console.log(endPoint, apiEndpoint) const args = [endPoint, apiEndpoint]; if (args) { args.push(signal); diff --git a/oldSrc/store/modules/content/actions.js b/oldSrc/store/modules/content/actions.js index 7a85b41..aba6382 100644 --- a/oldSrc/store/modules/content/actions.js +++ b/oldSrc/store/modules/content/actions.js @@ -181,7 +181,7 @@ export default { [ mutation, slug ] ) { const api = new DataFetcher(state.apiUrl); - const payload = await api.requestSettingApiEndPoint(slug, "/wp-json/wp/v2/pages"); + const payload = await api.requestSettingApiEndPoint(slug, "/wp-json/wp/v2/pages-"); commit(mutation, payload); return payload; }, diff --git a/package.json b/package.json index f561712..8f2ad77 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "mct", "private": true, - "version": "1.6.1", + "version": "1.7.0", "type": "module", + "main": "dist/mct.js", "scripts": { "development": "run-p start dev", "dev": "vite --host", diff --git a/src/assets/css/base.css b/src/assets/css/base.css new file mode 100644 index 0000000..960282e --- /dev/null +++ b/src/assets/css/base.css @@ -0,0 +1,19 @@ +:root { + --body-color: rgba(0, 0, 0, 0.87); + --text-shadow: 0 0 4px white, 0 0 4px white, 0 0 4px white, 0 0 4px white; + --gray-color: #ececec; + --gray-color-light: #fafafa; + --primary-color: #e96f5f; + --label-color: #827e7e; + --embed-color: #fafafa; + + --padding-container: 200px; +} + +/* Set padding for container elements on screens with a maximum width of 1368px */ + +@media (max-width: 1368px) { + :root { + --padding-container: 20px; + } +} diff --git a/src/assets/css/components/collapsable.css b/src/assets/css/components/collapsable.css new file mode 100644 index 0000000..8669163 --- /dev/null +++ b/src/assets/css/components/collapsable.css @@ -0,0 +1,26 @@ +.n-collapse-item.n-collapse-item--left-arrow-placement { + outline: 1px solid #e0e0e0; + border: none; + border-radius: 3px; + padding: 8px; + box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 4px; +} + +.n-collapse + .n-collapse-item + .n-collapse-item__header + .n-collapse-item__header-main { + font-weight: 500; +} + +.n-collapse .n-collapse-item:not(:first-child) { + border-top: none !important; +} + +.n-collapse .n-collapse-item .n-collapse-item__header { + padding: 0px; +} + +.n-collapse .n-collapse-item__header-extra .bi { + fill: var(--primary-color); +} diff --git a/src/assets/css/components/container.css b/src/assets/css/components/container.css new file mode 100644 index 0000000..63cf265 --- /dev/null +++ b/src/assets/css/components/container.css @@ -0,0 +1,58 @@ +/* Components Styles */ + +.main { + display: flex; + flex-direction: column; +} + +.main-content { + position: relative; + max-width: 1368px; + margin: 0px auto; + padding: 0px 24px; +} + +.main-content--sub { + margin-bottom: 64px; +} + +.container-elements { + display: flex; + justify-content: end; +} + +.container-elements--table { + display: flex; + gap: 12px; + padding-bottom: 16px; +} + +.container-elements--theme { + padding: 15px 65px; +} + +.container-elements__selects { + display: flex; + gap: 12px; +} + +.container-input-card { + display: flex; + gap: 8px; + justify-content: end; +} + +.element-hidden { + display: none !important; +} + +@media (max-width: 800px) { + .container-elements--table { + display: flex; + flex-direction: column; + gap: 8px; + } + .container-input-card { + flex-direction: column; + } +} diff --git a/src/assets/css/components/filter-suggestion.css b/src/assets/css/components/filter-suggestion.css new file mode 100644 index 0000000..30a0ab7 --- /dev/null +++ b/src/assets/css/components/filter-suggestion.css @@ -0,0 +1,65 @@ +.filter-suggestion { + height: 100%; + min-height: 520px; + overflow-y: auto; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + flex-direction: column; + background-color: rgb(254, 254, 254); +} + +.filter-suggestion-center { + justify-content: center; +} + +.filter-suggestion-title { + text-align: center; + font-size: 24px; + padding-bottom: 48px; + font-weight: 400; +} + +.filters-container { + gap: 8px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(600px, 1fr)); + grid-gap: 10px; +} + +.filter-container-suggestion { + overflow: hidden; +} + +.filter-title { + height: 18px; +} +.filter-text-suggestion { + text-align: initial; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.filter-description { + font-size: 11px; + height: 14px; +} + +/* MediaQuery */ + +@media (max-width: 1300px) { + .filters-container { + display: flex; + flex-direction: column; + } +} + +@media (max-width: 768px) { + .filter-suggestion { + display: block; + } +} diff --git a/src/assets/css/components/label.css b/src/assets/css/components/label.css new file mode 100644 index 0000000..b9e8ae9 --- /dev/null +++ b/src/assets/css/components/label.css @@ -0,0 +1,10 @@ +/* Label */ +.n-form-item.n-form-item--top-labelled .n-form-item-label { + align-items: center; + padding: 0px; +} + +.n-form-item-label__text { + color: var(--label-color); + font-size: 0.65rem; +} diff --git a/src/assets/css/components/main-content.css b/src/assets/css/components/main-content.css new file mode 100644 index 0000000..6c48f1f --- /dev/null +++ b/src/assets/css/components/main-content.css @@ -0,0 +1,27 @@ +.main-content { + margin-top: 16px; +} + +.map-section { + min-height: 520px; +} + +@media (max-width: 475px) { + .map-section { + min-height: 420px; + } +} + +.main-title { + margin: 0px; + padding: 0px; + font-weight: 700; + font-size: 1.5rem; +} + +.sub-title { + margin: 0px; + padding: 0px; + font-weight: 400; + font-size: 1.25rem; +} diff --git a/src/assets/css/components/main-footer.css b/src/assets/css/components/main-footer.css new file mode 100644 index 0000000..185f978 --- /dev/null +++ b/src/assets/css/components/main-footer.css @@ -0,0 +1,75 @@ +.main-card-footer-container { + display: block; +} + +.main-card-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 18px; + margin-bottom: 12px; +} + +.main-card-footer--mobile { + align-items: initial; + gap: 14px; +} + +.main-card-footer-mobile { + display: none; +} + +.main-card-footer__legend { + color: gray; + font-size: 14px; + font-weight: 400; +} + +.main-card-footer__buttons { + display: flex; + gap: 8px; +} + +.main-card-footer__buttons--mobile { + justify-content: space-between; +} + +.main-card-footer-dates { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* MediaQuery */ + +@media (max-width: 1200px) { + .main-card-footer { + margin: 0px 0px 12px; + flex-direction: column; + gap: 8px; + } + .main-card-footer-container { + display: flex; + flex-direction: column; + } + .main-card-footer__buttons { + justify-items: start; + margin-bottom: 12px; + flex-wrap: wrap; + justify-content: center; + width: 100%; + gap: 12px; + } + .main-card-footer-mobile { + display: flex; + } + .main-card-footer-container-mobile { + display: block; + } + .main-card-footer-dates { + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + gap: 4px 12px; + } +} diff --git a/src/assets/css/components/main-header.css b/src/assets/css/components/main-header.css new file mode 100644 index 0000000..5541643 --- /dev/null +++ b/src/assets/css/components/main-header.css @@ -0,0 +1,90 @@ +.main-header { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + padding-top: 6px; + padding-left: var(--padding-container); + padding-right: var(--padding-container); + background-color: var(--embed-color); + box-shadow: rgba(0, 0, 0, 0.35) 0px 2px 36px -28px inset; +} + +.main-header-container { + display: flex; + gap: 32px; + overflow: auto; + max-width: 100%; + align-items: center; + height: 70px; + overflow: hidden; +} + +.main-header__label { + color: #4a4a4a; + font-size: 0.9em; +} + +.main-header-form { + display: flex; + align-items: center; + gap: 24px; +} + +.main-header-form .n-tabs-tab__label { + height: 20.5167px; +} + +.custom-hr { + height: 48px; + border-left: 0px; + border-top: 0px; + opacity: 50%; + border-bottom: 1px solid; + border-right: 1px solid; +} + +.filter-mobile-button { + display: flex; + justify-content: center; +} + +.main-header__tab-label { + display: block; +} + +.main-header__tab-icon { + display: none; +} + +@media (max-width: 1368px) { + .main-header-container { + gap: 12px; + height: auto; + padding-bottom: 12px; + } + .main-header-form { + flex-direction: column; + gap: 6px; + } + .custom-hr { + height: 70px; + } + .main-header__tab-label { + display: none; + } + .main-header__tab-icon { + display: block; + } +} + +@media (max-width: 475px) { + .main-header-container { + flex-direction: column; + gap: 6px; + } + .custom-hr { + height: 0px; + width: 70px; + } +} diff --git a/src/assets/css/components/map.css b/src/assets/css/components/map.css new file mode 100644 index 0000000..502b791 --- /dev/null +++ b/src/assets/css/components/map.css @@ -0,0 +1,8 @@ +.map-element-container { + display: flex; + gap: 12px; +} + +.map-content { + width: 100%; +} diff --git a/src/assets/css/components/modal.css b/src/assets/css/components/modal.css new file mode 100644 index 0000000..819218f --- /dev/null +++ b/src/assets/css/components/modal.css @@ -0,0 +1,40 @@ +.custom-card-body.n-scrollbar { + padding: 0px; + font-size: 1rem; +} + +.custom-card.n-card .n-scrollbar-content { + padding: 12px 32px; +} + +.custom-card.n-card > .n-card__content { + padding: 0px; +} + +.custom-card .n-card-header { + border-bottom: 1px solid #eee; +} + +.custom-card-body { + max-height: calc(100vh - 170px); + overflow-y: hidden; +} + +.custom-card-body--tabs { + height: calc(100vh - 170px); +} + +.custom-card-body > .n-scrollbar-container > .n-scrollbar-content { + padding: 0px; +} +.custom-card-body .n-tabs-nav--line-type.n-tabs-nav--top.n-tabs-nav { + padding: 0px 25px; +} + +.custom-card-body .n-tabs-tab__label { + padding: 4px 0px 8px; +} + +.custom-card-body h3 { + font-size: 1.3rem; +} diff --git a/src/assets/css/components/select.css b/src/assets/css/components/select.css new file mode 100644 index 0000000..cc76183 --- /dev/null +++ b/src/assets/css/components/select.css @@ -0,0 +1,142 @@ +/* + +.select .n-base-selection-label { + background: var(--gray-color); +} + +.select .n-base-selection-label:hover { + background-color: var(--primary-color); +} + +.select .n-base-selection--active .n-base-selection-label, +.select .n-base-selection--active.n-base-selection--focus +{ + background-color: var(--primary-color) !important; +} + +.select .n-base-selection-label, +.n-base-selection-placeholder__inner { + color: black; + font-weight: 600; +} + +.select .n-base-selection--active .n-base-selection-label .n-base-selection-placeholder__inner, +.select .n-base-selection--active.n-base-selection--focus .n-base-selection-placeholder__inner, +.select .n-base-selection--active .n-base-selection-label .n-base-icon.n-base-suffix__arrow, +.select .n-base-selection--active.n-base-selection--focus .n-base-icon.n-base-suffix__arrow, +.select .n-base-selection-label:hover .n-base-selection-placeholder__inner, +.select .n-base-selection-label:hover .n-base-icon.n-base-suffix__arrow, +.select .n-base-selection--active.n-base-selection--focus .n-base-selection-input { + color: white; +} + +.select .n-base-selection__border { + border: 0px solid; +} + +.select .n-base-icon.n-base-suffix__arrow { + color: var(--primary-color); +} + +.n-base-select-menu .n-base-select-option.n-base-select-option--pending::before { + background-color: var(--primary-color); +} + +.n-base-icon.n-base-select-option__check { + outline: 2px solid white; + outline-offset: 1px; +} + +.n-base-select-option.n-base-select-option--selected.n-base-select-option--show-checkmark, +.n-base-select-menu .n-base-select-option .n-base-select-option__check, +.n-base-selection .n-base-loading, +.n-base-select-menu .n-base-select-option.n-base-select-option--selected, +.n-base-select-menu .n-base-select-option +{ + color: white; +} + +.n-base-selection.n-base-selection--active.n-base-selection--focus { + box-shadow: none; +} + +.n-base-select-menu .n-base-select-option.n-base-select-option--selected.n-base-select-option--pending::before { + background-color: var(--primary-color); +} + +.v-vl-items { + background-color: var(--primary-color); +} + +.n-select-menu { + margin: 0px; + box-shadow: none; +} + +*/ + +.n-form-item-feedback-wrapper { + min-height: 0 !important; +} + +.start-datepicker .n-base-selection__border { + border-right-color: transparent; +} + +.end-datepicker .n-base-selection__border { + border-left-color: transparent; +} + +.start-datepicker .n-input.n-input--resizable.n-input--stateful { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.end-datepicker .n-input.n-input--resizable.n-input--stateful { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +/* Selects styles */ + +.sub-select-container { + background-color: var(--embed-color); + box-shadow: rgba(0, 0, 0, 0.35) 0px -2px 36px -28px inset; +} + +.mct-select { + width: 200px; + z-index: 0; +} + +.mct-select-dose { + width: 140px; + z-index: 0; +} + +.mct-selects { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: flex-start; + max-width: 1368px; + margin: 0px auto; + padding: 0px 24px 24px; +} + +.mct-selects--modal { + flex-direction: column; +} + +.n-base-select-option__content { + z-index: 0 !important; +} + +@media (max-width: 1368px) { + .mct-selects { + background-color: white; + gap: 12px; + box-shadow: none; + align-items: center; + } +} diff --git a/src/assets/css/components/slider.css b/src/assets/css/components/slider.css new file mode 100644 index 0000000..8829b8a --- /dev/null +++ b/src/assets/css/components/slider.css @@ -0,0 +1,64 @@ +.n-slider-mark { + color: var(--label-color); + font-size: 12px; + font-weight: 500; +} + +.n-slider .n-slider-marks .n-slider-mark { + transform: translateX(-50%) translateY(-20%); +} + +.year-slider { + display: flex; + gap: 14px; + margin-top: 24px; + align-items: center; +} + +.n-slider-handle-indicator.n-slider-handle-indicator--top { + background-color: #32a1e6; + font-weight: 500; +} + +.mandatory-vaccine-years { + opacity: 100%; + cursor: auto !important; +} + +.mandatory-vaccine-years .n-slider-rail { + background-color: white; + height: 1px; +} +.mandatory-vaccine-years:hover .n-slider-rail { + background-color: white; +} + +.mandatory-vaccine-years.n-slider .n-slider-rail__fill { + background-color: #32a1e6; +} + +.mandatory-vaccine-years.n-slider:hover .n-slider-rail__fill { + background-color: #32a1e6; +} + +.mandatory-vaccine-years.n-slider.n-slider--disabled { + opacity: 100%; +} + +.mandatory-vaccine-years.n-slider.n-slider--active .n-slider-rail { + background-color: white; +} + +.mandatory-vaccine-years.n-slider.n-slider--active .n-slider-rail__fill { + background-color: #32a1e6; +} + +.span-date { + white-space: nowrap; + padding: 0px 6px; + font-size: 14px; +} + +.span-date--more-padding { + padding: 12px 6px; +} diff --git a/src/assets/css/components/tab.css b/src/assets/css/components/tab.css new file mode 100644 index 0000000..a5f2b35 --- /dev/null +++ b/src/assets/css/components/tab.css @@ -0,0 +1,70 @@ +/* Tabs */ +.n-tabs.n-tabs--segment-type.n-tabs--medium-size.n-tabs--top { + width: fit-content; +} + +.n-tabs-nav .n-tabs-rail { + width: fit-content; + border-top-left-radius: 22px; + border-bottom-left-radius: 22px; + border-top-right-radius: 22px; + border-bottom-right-radius: 22px; + padding: 0px; +} + +.n-tabs-nav .n-tabs-rail .n-tabs-tab:first-child { + border-top-left-radius: 22px !important; + border-bottom-left-radius: 22px !important; +} + +.n-tabs-nav .n-tabs-rail .n-tabs-tab[data-name='table'], +.n-tabs-nav .n-tabs-rail .n-tabs-tab[data-name='immunizers'] { + border-top-right-radius: 22px !important; + border-bottom-right-radius: 22px !important; +} + +.n-tabs-nav .n-tabs-rail .n-tabs-tab { + background-color: var(--gray-color); + color: black; +} + +.n-tabs-nav .n-tabs-rail .n-tabs-tab--disabled { + color: #ccc; + background-color: var(--gray-color-light); +} + +.n-tabs-nav .n-tabs-rail .n-tabs-tab--disabled:hover { + color: #ccc !important; +} + +.n-tabs-tab-wrapper .n-tabs-tab { + padding: 8px 24px; +} + +.n-tabs-nav { + display: flex; + justify-content: space-between; +} + +.n-tabs .n-tabs-rail .n-tabs-capsule { + display: none; +} + +.n-tabs .n-tabs-rail .n-tabs-tab-wrapper .n-tabs-tab.n-tabs-tab--active { + background-color: var(--primary-color); + transition: background-color 0.3s; + color: white; + font-weight: 400; +} + +.n-tabs-tab__label { + font-size: 0.9em; +} + +.n-tabs.n-tabs--line-type .n-tabs-tab { + padding: 2px 0px; +} + +.n-tabs-nav-scroll-content { + border-bottom-width: 3px; +} diff --git a/src/assets/css/components/table.css b/src/assets/css/components/table.css new file mode 100644 index 0000000..97a489c --- /dev/null +++ b/src/assets/css/components/table.css @@ -0,0 +1,99 @@ +.n-data-table-th.n-data-table-th--hover { + color: white; +} + +.n-data-table-th.n-data-table-th--sortable:hover { + color: white; +} + +.n-data-table + .n-data-table-th.n-data-table-th--sortable:hover + .n-data-table-sorter { + color: var(--gray-color); +} + +.n-data-table-tr .n-data-table-tr--striped, +.n-data-table-tr .n-data-table-tr--striped:hover, +.n-data-table .n-data-table-tr.n-data-table-tr--striped, +.n-data-table .n-data-table-tr:not(.n-data-table-tr--summary):hover, +.n-data-table-tr { + background: white; +} + +.n-data-table-tr .n-data-table-th:first-child, +.n-data-table-tr .n-data-table-td:first-child { + border-top-left-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; +} + +.n-data-table-tr .n-data-table-th:last-child, +.n-data-table-tr .n-data-table-td:last-child { + border-top-right-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; +} + +.n-data-table + .n-data-table-tr.n-data-table-tr--striped + .n-data-table-td.n-data-table-td--hover { + background: #fadfdb; +} + +.n-data-table + .n-data-table-tr:not(.n-data-table-tr--summary):hover + > .n-data-table-td { + background: #f6f6f6; +} + +.n-data-table .n-data-table-th .n-data-table-sorter.n-data-table-sorter--desc, +.n-data-table .n-data-table-th .n-data-table-sorter.n-data-table-sorter--asc { + color: white; +} + +.n-data-table-tr { + font-weight: 500; +} + +/* MediaQuery */ + +@media (max-width: 800px) { + .table-custom .n-pagination { + justify-content: space-between; + min-width: 100%; + } +} + +/* Collapsable */ + +.collapse-table { + margin-top: 12px; +} +.collapse-table table { + border-spacing: 0 8px !important; +} + +.collapse-table .n-data-table-thead { + display: none; +} + +.collapse-table .n-data-table-tr td:first-child { + border-left: 1px solid #d1d1d1; + border-bottom: 1px solid #d1d1d1; + border-top: 1px solid #d1d1d1; +} + +.collapse-table .n-data-table-tr td:last-child { + border-right: 1px solid #d1d1d1; + border-bottom: 1px solid #d1d1d1; + border-top: 1px solid #d1d1d1; +} + +/* Custom pagination */ + +.n-input.n-input--resizable.n-input--stateful { + pointer-events: none; +} + +.n-pagination.n-pagination--simple + .n-input.n-input--resizable.n-input--stateful { + --n-border: none !important; +} diff --git a/src/assets/css/map-chart-table-legend.css b/src/assets/css/map-chart-table-legend.css new file mode 100644 index 0000000..bb0fc52 --- /dev/null +++ b/src/assets/css/map-chart-table-legend.css @@ -0,0 +1,79 @@ +/* Map Legend */ + +.mct-legend { + position: absolute; + bottom: 15px; + right: 60px; + user-select: none; + pointer-events: none; + height: 52px; +} + +.mct-legend-svg { + width: 220px; +} + +.mct-legend__gradient { + position: relative; + box-shadow: + 0 0 2px white, + 0 0 2px white, + 0 0 2px white, + 0 0 2px white; +} + +@media (max-width: 800px) { + .mct-legend { + bottom: 0px; + right: 0px; + } + .mct-legend-svg { + width: 190px; + } +} + +/* Tooltip map styles */ + +.mct-tooltip { + display: none; + position: fixed; + opacity: 95%; + background-color: var(--primary-color); + border-radius: 5px; + padding: 12px; + font-size: 14px; + z-index: 1000; + user-select: none; + pointer-events: none; + min-width: 100px; + max-width: 400px; +} + +.mct-tooltip__title { + font-weight: 600; + color: white; + margin-bottom: 4px; +} + +.mct-tooltip__title--sub { + font-size: x-small; + font-weight: 600; + color: white; + margin-top: 2px; + margin-bottom: 2px; +} + +.mct-tooltip__result { + font-weight: 600; + color: white; + font-size: 12px; + margin: -3px 0px; +} + +.mct-tooltip__result--sub { + font-size: 11px; +} + +.mct-tooltip__result--sub:last-child { + margin-bottom: 1px; +} diff --git a/src/assets/css/map-chart-table.css b/src/assets/css/map-chart-table.css new file mode 100644 index 0000000..d54e997 --- /dev/null +++ b/src/assets/css/map-chart-table.css @@ -0,0 +1,216 @@ +/* Map styles */ + +.map-container { + min-height: 440px !important; +} + +.mct-canva { + width: 100%; + height: 440px; + display: flex; + justify-content: center; +} + +.mct-canva__chart { + padding-top: 25px; + width: 100%; +} + +.mct__canva-section { + position: relative; +} + +/* Map Year */ + +.mct-canva-year { + border-radius: 0.25rem; + color: var(--body-color); + font-size: 1.5rem; + font-weight: 700; + margin: 0px auto; + max-width: fit-content; + padding: 6px 24px; + position: absolute; + right: 10px; + bottom: 5px; + opacity: 0; + transition: + visibility 0s, + opacity 0.5s ease-in-out; + user-select: none; +} + +/* Map Legend */ + +.mct-legend { + width: 195px; + position: absolute; + bottom: 20px; + right: 10px; + display: flex; + flex-direction: column; + user-select: none; + pointer-events: none; +} + +.mct-legend__gradient { + position: relative; + margin: auto; + box-shadow: + 0 0 2px white, + 0 0 2px white, + 0 0 2px white, + 0 0 2px white; +} + +.mct-legend__gradient-box { + position: absolute; + display: flex; + z-index: 40; +} + +.mct-legend__gradient-box-content { + width: 10px; + height: 10px; + margin: 0px 1px; +} + +.mct-legend__content-box { + display: flex; + justify-content: space-between; + font-size: 0.6rem; + text-shadow: var(--text-shadow); + color: #1e1e1e; + margin-left: 22px; + margin-right: 40px; +} + +.mct-legend-box-0 { + background-color: #9c3f33; +} + +.mct-legend-box-1 { + background-color: #cf5443; +} + +.mct-legend-box-2 { + background-color: #e75e4b; +} + +.mct-legend-box-3 { + background-color: #ea7262; +} + +.mct-legend-box-4 { + background-color: #ed8678; +} + +.mct-legend-box-5 { + background-color: #f3aea5; +} + +.mct-legend-box-6 { + background-color: #f6c2bc; +} + +.mct-legend-box-7 { + background-color: #a0d1f2; +} + +.mct-legend-box-8 { + background-color: #32a1e6; +} + +.mct-legend-box-9 { + background-color: #0179da; +} + +.mct-legend-box-10 { + background-color: #016fc4; +} + +.mct-legend-box-11 { + background-color: #005ca1; +} + +.mct-legend-box-text { + font-size: 0.65em; + white-space: nowrap; + background-color: white; + position: relative; + top: 18px; + left: 0; +} + +.mct-legend-box-text__line { + border-right: 1px solid; + border-color: red; + position: absolute; + top: -12px; + left: -10px; + padding: 10px; + z-index: 0; +} + +.mct-legend-box-text__line--end { + border-color: blue; +} + +.mct-legend-box-text__content { + border: 1px solid gray; + padding: 1px 4px; + color: black; + position: absolute; + border-radius: 0.23rem; + left: 0; + z-index: 40; + background-color: white; +} + +.mct-legend-box-text--end { + top: 18px; + left: auto; + right: 0; +} + +.mct-legend-box-text__line--end { + top: -12px; + left: auto; + right: 18px; +} + +.mct-legend-box-text__content--end { + left: auto; + top: 0; + right: 0; + z-index: 40; + background-color: white; +} + +.mct-legend__content { + display: flex; + gap: 38px; + justify-content: space-between; + white-space: nowrap; +} + +.mct-legend-box-start { + background-color: #692a22; + width: 19px; +} + +.mct-legend-box-end { + background-color: #00457c; + width: 19px; +} + +/* MediaQuery */ + +@media (max-width: 800px) { + .map-container { + min-height: 337.6px !important; + } + .mct-canva { + height: 70vw; + } +} diff --git a/src/assets/css/style.css b/src/assets/css/style.css new file mode 100644 index 0000000..5412bf2 --- /dev/null +++ b/src/assets/css/style.css @@ -0,0 +1,110 @@ +@import url('./base.css'); +@import url('./components/collapsable.css'); +@import url('./components/container.css'); +@import url('./components/filter-suggestion.css'); +@import url('./components/label.css'); +@import url('./components/main-content.css'); +@import url('./components/main-footer.css'); +@import url('./components/main-header.css'); +@import url('./components/map.css'); +@import url('./components/modal.css'); +@import url('./components/select.css'); +@import url('./components/slider.css'); +@import url('./components/tab.css'); +@import url('./components/table.css'); +@import url('./map-chart-table-legend.css'); +@import url('./map-chart-table.css'); + +/* + * Fix table resize rows error. + * + * After Resize to 20 and go back to 10, for example + * browser will show an height page error. + * + * */ + +.v-binder-follower-container { + position: initial; +} + +.mct-scrollbar::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.mct-scrollbar::-webkit-scrollbar-track { + border-radius: 100vh; + background: #f7f4ed; +} + +.mct-scrollbar::-webkit-scrollbar-thumb { + background: #e0cbcb; + border-radius: 100vh; + border: 3px solid #f6f7ed; +} + +.mct-scrollbar::-webkit-scrollbar-thumb:hover { + background: #c0a0b9; +} + +.pulse-button { + position: relative; + overflow: visible; +} + +.pulse-button::after { + content: ''; + position: absolute; + width: 100%; + height: 110%; + outline: 8px solid #18a058; + outline-offset: -6px; + border-radius: 34px; + animation: pulse 2s infinite; + z-index: 0; +} + +.pulse-button:hover::after { + outline-color: #36ad6a; +} + +.pulse-button:active::after { + outline-color: #0c7a43; +} + +.pulse-button:focus-visible { + border: 1px solid black; +} + +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 0.7; + } + 70% { + transform: scale(1.05); + opacity: 0; + } + 100% { + transform: scale(1.05); + opacity: 0; + } +} + +.vbr, +.vbr ::before, +.vbr ::after { + box-sizing: unset !important; +} + +/* Remove fade/scale selects animations */ +.fade-in-scale-up-transition-enter-active, +.fade-in-scale-up-transition-leave-active { + transition: none !important; +} + +.fade-in-scale-up-transition-enter-from, +.fade-in-scale-up-transition-leave-to { + opacity: 1 !important; + transform: none !important; +} diff --git a/src/assets/images/abandono.svg b/src/assets/images/abandono.svg new file mode 100644 index 0000000..fc83c0d --- /dev/null +++ b/src/assets/images/abandono.svg @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/cc.png b/src/assets/images/cc.png new file mode 100644 index 0000000000000000000000000000000000000000..1bd49347ee6ac479b7b0d1c35fe8b8a36bb02f23 GIT binary patch literal 695 zcmV;o0!aOdP)<^$jrxofPXA4 zEM{hAKtVu}l#x(SP!JFhpP--L-{0@=?+p$Or>LeI8yg@XAl&>;T>t<82y{|TQvm<} z|NsC04Do*VlmGw%0ZBwbR7i=nmWh&sAPk0EK-Vaj=ji*t=q7+gtWw+VX4=8ATR5R;LF zj^t;PiZ&|w0_?cl&QYM*@<2Wc*&^0xZ4hm9VA7%BE%G`o8I!uh%eIc>9k`#M9R%9Y z!gNX|aNu@w6yAD7D6V?7x)IIN41JRfj2c?4#(Q+kIWH`{=>9$0P>oGrIE!P^GW*#?PHjKzZEMbVN5#mTwO#VqLGNK6KxwUB4WR}bx)YCbtBhO363gw$>)P0K9c?c+$VH=B;aYk~x#unY z2DmBu%n + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/hom_geo.svg b/src/assets/images/hom_geo.svg new file mode 100644 index 0000000..7f15095 --- /dev/null +++ b/src/assets/images/hom_geo.svg @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/hom_vac.svg b/src/assets/images/hom_vac.svg new file mode 100644 index 0000000..9ceea6e --- /dev/null +++ b/src/assets/images/hom_vac.svg @@ -0,0 +1,244 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/legend.png b/src/assets/images/legend.png new file mode 100644 index 0000000000000000000000000000000000000000..c9fefd08bfbf71319578981a41c9e7c27f26469e GIT binary patch literal 6905 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 + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/meta.svg b/src/assets/images/meta.svg new file mode 100644 index 0000000..3cd8bad --- /dev/null +++ b/src/assets/images/meta.svg @@ -0,0 +1,248 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sim + Não + + + + diff --git a/src/assets/images/ri-alert-line.svg b/src/assets/images/ri-alert-line.svg new file mode 100644 index 0000000..36b56f3 --- /dev/null +++ b/src/assets/images/ri-alert-line.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/images/sbim.png b/src/assets/images/sbim.png new file mode 100644 index 0000000000000000000000000000000000000000..76855e64b4130f523f3c4962ff59aee4530d2848 GIT binary patch literal 12703 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 + */ + async setCanvas() { + const self = this + const canvas = document.createElement('canvas') + canvas.id = 'canvas-generator' + canvas.width = self.canvasWidth + canvas.height = self.canvasHeight + canvas.style.backgroundColor = 'white' + self.canvas = canvas + self.ctx = canvas.getContext('2d') + + // Set canvas color + self.ctx.fillStyle = 'white' + self.ctx.fillRect(0, 0, self.canvas.width, self.canvas.height) + + /** @type{Promise[]} **/ + const promises = [] + self.images.forEach((img) => { + promises.push( + self.addImage( + img.image, + img.height, + img.width, + img.posX, + img.posY + ) + ) + }) + + await Promise.all(promises) + + self.addText() + } + + /** + * Calcula a redução proporcional de dimensões baseada em um fator. + * @param {number} height - Altura original. + * @param {number} width - Largura original. + * @param {number} factor - Fator de multiplicação (ex: 1, 0.9, etc). + * @returns {{ nHeight: number, nWidth: number }} Objeto com nova altura e largura. + */ + reduceProportion(height, width, factor) { + const nHeight = height * factor + const nWidth = width * factor + + return { nHeight, nWidth } + } + + /** + * Carrega uma imagem e a desenha no contexto do canvas. + * Ajusta o tamanho proporcionalmente se a largura exceder o canvas. + * @param {string} image - URL da imagem. + * @param {number} [height] - Altura desejada. + * @param {number} [width] - Largura desejada. + * @param {number} [posX] - Posição X (centraliza se omitido). + * @param {number} [posY] - Posição Y (centraliza se omitido). + * @returns {Promise} + */ + addImage(image, height, width, posX, posY) { + const self = this + const canvas = this.canvas + const ctx = this.ctx + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = function () { + const x = posX ? posX : canvas.width / 2 - img.width / 2 + const y = posY ? posY : canvas.height / 2 - img.height / 2 + // Assuming 'ctx' is a 2D rendering context of the canvas + ctx.drawImage(img, x, y, img.width, img.height) + resolve() + } + img.onerror = (e) => reject(new Error('Image load failed')) + + img.src = image + let factor = 1 + let result = self.reduceProportion( + img.naturalHeight, + img.naturalWidth, + factor + ) + while (result.nWidth > self.canvasWidth) { + factor -= 0.01 + result = self.reduceProportion( + img.naturalHeight, + img.naturalWidth, + factor + ) + } + img.height = height ?? result.nHeight + img.width = width ?? result.nWidth + }) + } + + /** + * Desenha texto no canvas com quebra de linha automática baseada na largura máxima. + * @param {string} text - O texto a ser escrito. + * @param {number} x - Posição inicial X. + * @param {number} y - Posição inicial Y. + * @param {number} [maxWidth=1390] - Largura máxima antes da quebra de linha. + * @param {number} [lineHeight=30] - Altura da linha. + * @returns {number} O número de vezes que a linha foi quebrada. + */ + drawTextWithLineBreaks(text, x, y, maxWidth = 1390, lineHeight = 30) { + const self = this + let lineBreakedTimes = 0 + const words = text.split(' ') + let line = '' + + for (const word of words) { + const testLine = line + word + ' ' + const { width } = self.ctx.measureText(testLine) + + if (width > maxWidth) { + self.ctx.fillText(line, x, y) + line = word + ' ' + y += lineHeight + lineBreakedTimes++ + } else { + line = testLine + } + } + + self.ctx.fillText(line, x, y) + + return lineBreakedTimes + } + + /** + * Adiciona os textos configurados (título, subtítulo, fonte, mensagem) ao canvas. + * @returns {void} + */ + addText() { + const self = this + + if (!self.title) { + return + } + + self.ctx.font = 'bold 25px Arial' + self.ctx.fillStyle = '#222' + let xText = 10 + let yText = 30 + const lineBreakedTimes = self.drawTextWithLineBreaks( + self.title, + xText, + yText + ) + + if (self.subTitle) { + self.ctx.font = '17px Arial' + self.ctx.fillStyle = '#222' + yText = lineBreakedTimes ? (lineBreakedTimes + 1) * 45 : 55 + xText = 12 + self.drawTextWithLineBreaks(self.subTitle, xText, yText) + } + + if (self.source) { + self.ctx.font = '12px Arial' + self.ctx.fillStyle = '#222' + yText = self.yTextSource + xText = 230 + self.drawTextWithLineBreaks(self.source, xText, yText) + } + + if (self.message) { + self.ctx.font = '700 70px Arial' + self.ctx.fillStyle = 'rgba(100, 100, 100, 0.5)' + yText = 560 + xText = -130 + self.ctx.rotate((-20 * Math.PI) / 180) + self.drawTextWithLineBreaks(self.message, xText, yText) + } + } + + /** + * Gera o canvas e dispara o download da imagem em formato PNG. + * @returns {Promise} + */ + async download() { + const self = this + await self.setCanvas() + const link = document.createElement('a') + link.href = self.canvas.toDataURL('image/png') + link.download = 'image' + link.click() + } +} + +export default CanvasDownload diff --git a/src/common.js b/src/common.js new file mode 100644 index 0000000..eb2dc4c --- /dev/null +++ b/src/common.js @@ -0,0 +1,70 @@ +/** + * Formats form data and tab settings into a clean object for API or Router usage. + * This function filters the `form` object: + * - Copies 'local' and 'city' only if they have length (are not empty strings/arrays). + * - Copies 'periodEnd' and 'periodStart' if they exist (are truthy). + * - Explicitly ignores list fields (e.g., 'cities', 'doses', 'years', etc). + * - Copies any other keys if they are truthy. + * - Adds 'tab' and 'tabBy' if provided. + * + * @param {Object} params - The input parameters. + * @param {Record} [params.form] - The source form data object containing filters. + * @param {string|number} [params.tab] - The current active tab identifier. + * @param {string} [params.tabBy] - The grouping criteria for the tab. + * @returns {Record} A new object containing only the valid/filtered properties. + */ +export const formatToApi = ({ form, tab, tabBy }) => { + /** @type {Record} */ + const routerResult = {} + if (form) { + for (let formField in form) { + switch (formField) { + case 'local': + if (form[formField] && form[formField].length) { + routerResult[formField] = form[formField] + } + break + case 'city': + if (form[formField] && form[formField].length) { + routerResult[formField] = form[formField] + } + break + case 'periodEnd': + case 'periodStart': + if (form[formField]) { + routerResult[formField] = form[formField] + } + break + case 'cities': + case 'doses': + case 'granularities': + case 'immunizers': + case 'locals': + case 'sicks': + case 'types': + case 'years': + // Do Nothing + break + default: + if (form[formField]) { + routerResult[formField] = form[formField] + } + break + } + } + } + + if (tab) { + routerResult.tab = tab + } else { + delete routerResult.tab + } + + if (tabBy) { + routerResult.tabBy = tabBy + } else { + delete routerResult.tabBy + } + + return routerResult +} diff --git a/src/components/config.js b/src/components/config.js new file mode 100644 index 0000000..24cd714 --- /dev/null +++ b/src/components/config.js @@ -0,0 +1,59 @@ +import { NConfigProvider, ptBR, NMessageProvider } from 'naive-ui' + +export default { + components: { NConfigProvider, NMessageProvider }, + setup() { + const lightThemeOverrides = { + common: { + primaryColor: '#e96f5f', + primaryColorHover: '#e96f5f', + primaryColorPressed: '#e96f5f', + fontSizeMedium: '.95rem', + }, + Slider: { + indicatorColor: '#e96f5f', + }, + Pagination: { + itemBorderRadius: '50%', + }, + Button: { + fontSizeMedium: '.95rem', + }, + Tabs: { + tabFontSizeMedium: '.95rem', + }, + DataTable: { + fontSizeMedium: '.95rem', + thColorHover: '#e96f5f', + thColor: '#ececec', + tdColorStriped: '#ececec', + thFontWeight: '500', + thIconColor: '#e96f5f', + }, + Select: { + peers: { + InternalSelectMenu: { + clearTransition: null, + fadeTransition: null, + slideUpTransition: null, + }, + }, + }, + } + return { + // Configuration for the provider + ptBR: ptBR, + lightThemeOverrides, + } + }, + template: ` + + + + + + `, +} diff --git a/src/components/main/card-components/chart.js b/src/components/main/card-components/chart.js new file mode 100644 index 0000000..281186b --- /dev/null +++ b/src/components/main/card-components/chart.js @@ -0,0 +1,480 @@ +import { + defineComponent, + ref, + onMounted, + onUnmounted, + computed, + watch, +} from 'vue' +import { NSelect, NEmpty } from 'naive-ui' +import { useContentStore, useChartStore } from '@/stores/index' +import { storeToRefs } from 'pinia' + +/** + * @typedef {{dataset: { label: string, data: string }, parsed: { y: string }, dataIndex: number }} context + */ + +import ChartDataLabels from 'chartjs-plugin-datalabels' + +import { + Chart, + LineController, + LineElement, + PointElement, + LinearScale, + Tooltip, + CategoryScale, + Legend, + // @ts-ignore +} from 'chartjs' + +Chart.register( + CategoryScale, + LineController, + LineElement, + PointElement, + LinearScale, + Tooltip, + Legend, + ChartDataLabels +) + +export default defineComponent({ + components: { NSelect, NEmpty }, + setup() { + const contentStore = useContentStore() + const { tabBy, acronyms, form, loading } = storeToRefs(contentStore) + const chartStore = useChartStore() + const { years, dataChart } = storeToRefs(chartStore) + + const formPopulated = computed(() => contentStore.selectsPopulated) + + const chartDefined = ref(true) + + /** + * @param {HTMLElement} chart + * @param {string} id + * @returns {void | HTMLElement} + */ + const getOrCreateLegendList = (chart, id) => { + const legendContainer = document.getElementById(id) + + if (!legendContainer) { + return + } + + let listContainer = legendContainer.querySelector('ul') + + if (!listContainer) { + listContainer = document.createElement('ul') + listContainer.style.display = 'flex' + listContainer.style.gap = '4px 12px' + listContainer.style.flexDirection = 'row' + listContainer.style.flexWrap = 'wrap' + listContainer.style.margin = '0' + listContainer.style.padding = '0' + + legendContainer.appendChild(listContainer) + } + + return listContainer + } + + /** @type {(label: string) => string} */ + const splitTextToChart = (label) => { + let labelSplited = label.split(' ') + let lastLabel = labelSplited[labelSplited.length - 1] + const vaccineName = labelSplited + .slice(0, labelSplited.length - 1) + .join(' ') + const acronym = + tabBy.value === 'immunizers' + ? acronyms.value.find((acronym) => + vaccineName.includes(acronym['nome_vacinabr']) + ) + : undefined + let labelAcronym = acronym + ? acronym['sigla_vacinabr'] + : labelSplited[0].substr(0, 3) + '.' + + if (form.value.granularity.toLowerCase() === 'municípios') { + labelSplited = label.split(',') + lastLabel = + ' ' + + labelSplited[1] + + ', ' + + labelSplited[2].substr(0, 6) + + '.' + } else if (label.includes(',')) { + labelSplited = label.split(',') + lastLabel = + labelSplited[1].split(' ')[0] + + ' ' + + labelSplited[1].split(' ')[2].substr(0, 3) + } + return `${labelAcronym} ${lastLabel}` + } + + /** @type {(value: string, context: { dataIndex: number, dataset: { label: string, data: string } }, signal: string) => string | null} */ + const formatter = (value, context, signal) => { + const dataset = context.dataset.data + // Get last populated year data index from dataset + let count = 1 + while (dataset[dataset.length - count] === null) { + count++ + } + if (context.dataIndex === dataset.length - count) { + const label = splitTextToChart(context.dataset.label) + // @ts-ignore + return signal + ? `${label} ${value}${signal}` + : `${label} ${Number(value).toLocaleString('pt-BR')}` + } + + return null + } + + /** @type {{ id: string, afterUpdate: (chart: Chart, args: any, options: { containerID: string}) => void}} */ + const htmlLegendPlugin = { + id: 'htmlLegend', + afterUpdate(chart, args, options) { + if (!document.getElementById(options.containerID)) { + return + } + const ul = getOrCreateLegendList(chart, options.containerID) + + if (!ul) { + return + } + + // Remove old legend items + while (ul.firstChild) { + ul.firstChild.remove() + } + + // Reuse the built-in legendItems generator + const items = + /** @type {{ [key: string]: string }[]} */ + (chart.options.plugins.legend.labels.generateLabels(chart)) + + items.forEach((item) => { + const li = document.createElement('li') + li.style.alignItems = 'center' + li.style.display = 'flex' + li.style.cursor = 'pointer' + li.style.flexDirection = 'row' + li.style.opacity = item.hidden ? '30%' : '100%' + li.style.border = '1px solid #ddd' + li.style.padding = '2px 4px' + li.style.borderRadius = '3px' + li.title = + 'Clique para' + + (item.hidden ? ' exibir ' : ' ocultar ') + + 'dado no gráfico' + + li.onclick = () => { + chart.setDatasetVisibility( + item.datasetIndex, + !chart.isDatasetVisible(item.datasetIndex) + ) + chart.update() + } + + if (!item.hidden) { + li.onmouseenter = () => { + li.style.borderColor = '#e96f5f' + } + li.onmouseleave = () => { + li.style.borderColor = '#ddd' + } + } + + // Color box + const boxSpan = document.createElement('span') + boxSpan.style.background = item.hidden + ? 'gray' + : item.fillStyle + boxSpan.style.borderColor = item.strokeStyle + boxSpan.style.borderWidth = item.lineWidth + 'px' + boxSpan.style.display = 'inline-block' + boxSpan.style.borderRadius = '50%' + boxSpan.style.height = '14px' + boxSpan.style.marginRight = '4px' + boxSpan.style.width = '14px' + + // Text + const textContainer = document.createElement('p') + textContainer.style.color = item.fontColor + textContainer.style.margin = '0' + textContainer.style.padding = '0' + textContainer.style.textDecoration = item.hidden + ? 'line-through' + : '' + + const text = document.createTextNode( + splitTextToChart(item.text) + ) + textContainer.appendChild(text) + + li.appendChild(boxSpan) + li.appendChild(textContainer) + ul.appendChild(li) + }) + }, + } + + /** @type {(context: { dataset: { label: string }, parsed: { y: string } }, signal: string) => string } */ + const formatterTooltip = (context, signal) => { + let label = context.dataset.label || '' + if (label.includes(',')) { + let resultNewLabel = /** @type {string[]} */ (label.split(',')) + const resultNewLabelSplited = /** @type {string} */ ( + resultNewLabel.shift() + ) + // Extract first value remove region code + const sickName = resultNewLabelSplited.split(' ')[0] + resultNewLabel.pop() + label = sickName + ' ' + resultNewLabel.join(', ') + } + + label += ': ' + + if (context.parsed.y !== null) { + label += signal + ? context.parsed.y + signal + : Number(context.parsed.y).toLocaleString('pt-BR') + } + + return label + } + + /** @type {(value: string, signal: string|null) => string} */ + const chartTicks = (value, signal = null) => { + return signal + ? String(value) + signal + : Number(value).toLocaleString('pt-BR') + } + + let chart = /** @type {Chart | null} */ (null) + + /** + * @param {string[] | null} labels + * @param {{ label: string, + * data: (string | null)[], + * backgroundColor: string, + * borderColor: string, + * borderWidth: number, + * }[]|null} datasets + */ + const renderChart = (labels, datasets) => { + if (!labels || !datasets || !formPopulated.value) { + const legend = document.querySelector('#legend-container') + if (legend) { + legend.innerHTML = '' + } + chartDefined.value = false + return + } + + chartDefined.value = true + + let signal = '' + if (form.value.type !== 'Doses aplicadas') { + signal = '%' + for (const dataset of datasets) { + dataset.data = dataset.data.map((number) => { + return Number(number).toFixed(2) + }) + } + } else { + for (const dataset of datasets) { + dataset.data = dataset.data.map((number) => { + return number + ? String(number).replace(/\./g, '') + : number + }) + } + } + + if (chart) { + chart.data.labels = labels + chart.data.datasets = datasets + chart.options.scales.y.ticks.callback = + /** @type{(value: string) => any} */ (value) => + chartTicks(value, signal) + chart.options.plugins.datalabels.formatter = + /** @type{(value: string, context: context) => any} */ ( + value, + context + ) => formatter(value, context, signal) + chart.options.plugins.tooltip.callbacks.label = + /** @type{(context: context) => any} */ (context) => + formatterTooltip(context, signal) + chart.update() + return + } + const plugin = { + id: 'customCanvasBackgroundColor', + /** @type {(chart: Chart, args: string[], options: { color: string }) => void} */ + beforeDraw: (chart, args, options) => { + const { ctx } = chart + ctx.save() + ctx.globalCompositeOperation = 'destination-over' + ctx.fillStyle = options.color || 'white' + ctx.fillRect(0, 0, chart.width, chart.height) + ctx.restore() + }, + } + try { + const chartElement = /** @type {Chart} */ ( + document.querySelector('#chart') + ) + const ctx = chartElement.getContext('2d') + chart = new Chart(ctx, { + type: 'line', + data: { + labels, + datasets, + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + animateRotate: true, + animateScale: true, + }, + scales: { + x: { + border: { + display: false, + }, + grid: { + color: 'rgba(127,127,127, .2)', + }, + ticks: { + color: 'rgba(127,127,127, 1)', + padding: 20, + font: { + size: 14, + }, + }, + }, + y: { + suggestedMin: 0, + suggestedMax: 100, + border: { + display: false, + }, + grid: { + color: 'rgba(127,127,127, .2)', + }, + ticks: { + callback: + /** @type {(value: string) => string} */ ( + value + ) => chartTicks(value, signal), + color: 'rgba(127,127,127, 1)', + padding: 20, + font: { + size: 14, + }, + }, + }, + }, + plugins: { + htmlLegend: { + // ID of the container to put the legend in + containerID: 'legend-container', + }, + legend: { + display: false, + }, + datalabels: { + align: /** @type {(context: { dataset: { borderColor: string }}) => number} */ function ( + context + ) { + return 5 + }, + borderRadius: '50', + padding: '3', + backgroundColor: 'rgba(255,255,255, 0.95)', + color: /** @type {(context: { dataset: { borderColor: string }}) => string} */ function ( + context + ) { + return context.dataset.borderColor + }, + font: { + size: 10, + weight: 'bold', + }, + display: 'auto', + formatter: + /** @type {(value: string, context: context, signal: string) => string|null} */ ( + value, + context + ) => formatter(value, context, signal), + }, + tooltip: { + callbacks: { + label: /** @type {(context: context) => string|null} */ ( + context + ) => formatterTooltip(context, signal), + }, + }, + }, + layout: { + padding: { + right: 150, + }, + }, + }, + plugins: [htmlLegendPlugin, plugin], + }) + } catch (e) { + // Do nothing + } + } + + onMounted(async () => { + await chartStore.setChartData() + renderChart(years.value, dataChart.value) + }) + + onUnmounted(() => { + chartStore.resetState() + }) + + watch( + () => form.value, + async (formValue) => { + // Avoid render before tab changed to chart/tables + if (Array.isArray(formValue.sickImmunizer)) { + await chartStore.setChartData() + renderChart(years.value, dataChart.value) + } + }, + { deep: true } + ) + + return { + chartDefined, + formPopulated, + loading, + } + }, + template: ` +
+
+
+ + +
+
+
+ `, +}) diff --git a/src/components/main/card-components/filter-suggestion.js b/src/components/main/card-components/filter-suggestion.js new file mode 100644 index 0000000..fac4393 --- /dev/null +++ b/src/components/main/card-components/filter-suggestion.js @@ -0,0 +1,118 @@ +import { NButton } from 'naive-ui' +import { storeToRefs } from 'pinia' +import { useContentStore, useModalStore } from '@/stores' +import { defineComponent, computed } from 'vue' + +export default defineComponent({ + components: { + NButton, + }, + setup() { + const contentStore = useContentStore() + const { autoFilters, extraFilterButton } = storeToRefs(contentStore) + + const modalStore = useModalStore() + const { + genericModal, + genericModalShow, + genericModalTitle, + genericModalLoading, + } = storeToRefs(modalStore) + + /** + * @param {string[]} array + */ + const shuffle = (array) => { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[array[i], array[j]] = [array[j], array[i]] + } + return array + } + + /** + * @param {any} element + */ + const selectFilter = (element) => { + Object.entries(element).forEach( + (/** @type{string[]} element */ [key, value]) => { + if (key === 'tab') { + contentStore.setTabField(value) + } else if (key === 'tabBy') { + contentStore.setTabByField(value) + } else if (key === 'filters') { + Object.entries(value).forEach( + (/** @type{string[]} element */ [fKey, fValue]) => { + contentStore.setFormField(fKey, fValue) + } + ) + } + } + ) + } + + const elements = computed(() => { + const result = autoFilters.value + return result ? shuffle(result).slice(-4) : null + }) + + /** + * @param {string} title + * @param {string} slug + */ + const handleExtraButton = async (title, slug) => { + genericModalLoading.value = true + genericModal.value = null + genericModalShow.value = !genericModalShow.value + genericModalTitle.value = title + try { + await modalStore.requestContent(slug) + } catch { + // Do Nothing + } + genericModalLoading.value = false + } + + return { + elements, + selectFilter, + extraFilterButton, + handleExtraButton, + } + }, + template: ` +
+
+ {{ extraFilterButton.title }} +
+
+
+

+ Explore a plataforma usando os filtros acima, ou selecione um dos exemplos abaixo +

+
+
+ +
+
+ {{ element.title }} +
+
{{ element.description }}
+
+
+
+
+
+ `, +}) diff --git a/src/components/main/card-components/map/index.js b/src/components/main/card-components/map/index.js new file mode 100644 index 0000000..f110064 --- /dev/null +++ b/src/components/main/card-components/map/index.js @@ -0,0 +1,25 @@ +import { defineComponent } from 'vue' + +import Map from '@/components/main/card-components/map/map' +import YearSlider from '@/components/main/card-components/map/year-slider' +import MapRange from '@/components/main/card-components/map/map-range' + +export default defineComponent({ + components: { + Map, + MapRange, + YearSlider, + }, + setup() {}, + template: ` +
+
+ +
+ + +
+
+
+ `, +}) diff --git a/src/components/main/card-components/map/map-range.js b/src/components/main/card-components/map/map-range.js new file mode 100644 index 0000000..f6ccce6 --- /dev/null +++ b/src/components/main/card-components/map/map-range.js @@ -0,0 +1,397 @@ +import { defineComponent, ref, watch } from 'vue' + +import { NCard } from 'naive-ui' + +import { useContentStore, useMapStore } from '@/stores/index' +import { storeToRefs } from 'pinia' + +export default defineComponent({ + components: { + NCard, + }, + setup() { + const contentStore = useContentStore() + const { form } = storeToRefs(contentStore) + + const mapStore = useMapStore() + const { mapData, mapTooltip } = storeToRefs(mapStore) + + /** + * @type {import('vue').Ref} + */ + const datasetValues = ref([]) + + /** + * @type {import('vue').Ref} + */ + const mapRangeSVG = ref(null) + + /** + * @type {import('vue').Ref} + */ + const maxVal = ref('---') + + /** + * @type {import('vue').Ref} + */ + const minVal = ref('--') + + /** + * Draws a line in an SVG element. + * @param {SVGSVGElement} svg - The SVG element to draw the line in. + */ + const drawLine = (svg) => { + svg.setAttribute('height', '0') + const offsetHeight = svg?.parentElement?.offsetHeight ?? 0 + svg.setAttribute('height', String(offsetHeight - 70)) + const line = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'line' + ) + line.setAttribute('x1', '20') + line.setAttribute('y1', '0') + line.setAttribute('x2', '20') + line.setAttribute('y2', '100%') + line.setAttribute('stroke', '#ccc') + line.setAttribute('stroke-width', '0.6') + svg.appendChild(line) + } + + /** + * Clears all circles from the map range. + */ + const clearCircles = () => { + const mapRange = document.querySelector('#map-range') + const circles = mapRange?.querySelectorAll('circle') + if (circles) { + circles.forEach((circle) => + circle?.parentElement?.removeChild(circle) + ) + } + } + + /** + * @param {SVGSVGElement} svg - The SVG element to query. + * @param {number} i - The index of the data item. + * @param {Array<{data: {value: any}}>} data - The array of data items. + * @param {boolean} [meta=false] - Whether to process meta data. + * @returns {SVGCircleElement | undefined} - The found circle element or undefined if not found. + */ + const samePercentCircle = (svg, i, data, meta = false) => + [...svg.querySelectorAll('circle')].find((el) => { + let result + let dataValue = data[i].data.value + if (meta) { + dataValue = parseInt(dataValue) === 0 ? 'Não' : 'Sim' + } + try { + const val = el.dataset.value + // @ts-ignore + result = JSON.parse(val).value + } catch (e) { + result = el.dataset.value + } + + return result == dataValue + }) + + // TODO: Make an assistant function to convert data and remove ts-ignores + /** + * @param {any} data - The data object containing map information. + */ + const handleMapChange = (data) => { + const svg = mapRangeSVG.value + if (!svg || !svg.parentNode) { + return + } + drawLine(svg) + clearCircles() + if (!data || !data.length) { + // Reset interface max/min values + maxVal.value = '---' + minVal.value = '---' + return + } + + const svgHeight = svg.getAttribute('height') + + /** @type { string | number } */ + let maxDataVal = String( + Math.max( + ...data.map((/** @type {{ data: string }} */ item) => + parseFloat(item.data) + ) + ) + ) + let defineMinVal = '0%' + const type = form.value.type + if (type === 'Doses aplicadas') { + maxDataVal = Number( + Math.max( + ...data.map( + (/** @type {{ data: { value: string } }} */ item) => + item.data.value.replace(/[.,]/g, '') + ) + ) + ).toLocaleString('pt-BR') + defineMinVal = '0' + } else if (type === 'Cobertura') { + maxDataVal = '120%' + } else if (type === 'Meta atingida') { + maxDataVal = 'Sim' + defineMinVal = 'Não' + } else { + maxDataVal = '100%' + } + + // Setting interface values + maxVal.value = maxDataVal + minVal.value = defineMinVal + + // If maxVal bigger than parent element add styles + + /** + * @type SVGSVGElement | null + */ + const maxValEl = svg.parentNode.querySelector('.max-val') + if (maxValEl) { + if (maxDataVal.toString().length > 4) { + maxValEl.style.border = '1px solid #f0f0f0' + maxValEl.style.boxShadow = + 'rgba(100, 100, 111, 0.2) 0px 7px 29px 0px' + } else { + maxValEl.style.border = '0px' + maxValEl.style.boxShadow = 'none' + } + } + + for (let i = 0; i < data.length; i++) { + let dataVal = data[i].data.value.replace(/[.,]/g, '') + if (data[i].data.value && data[i].data.value.includes('%')) { + dataVal = parseFloat(data[i].data.value) + } + + let y = 0 + let dataValue = JSON.stringify(data[i].data) + let samePercentCircleResult + if (type === 'Meta atingida') { + y = Number(svgHeight) - (dataVal / 1) * Number(svgHeight) + dataValue = + parseInt(data[i].data.value) === 0 ? 'Não' : 'Sim' + samePercentCircleResult = samePercentCircle( + svg, + i, + data, + true + ) + } else { + y = + Number(svgHeight) - + (dataVal / parseInt(maxDataVal.replace(/[.,]/g, ''))) * + Number(svgHeight) + samePercentCircleResult = samePercentCircle( + svg, + i, + data, + false + ) + } + + if ( + samePercentCircleResult && + samePercentCircleResult.dataset && + samePercentCircleResult.dataset.title + ) { + const newTitle = + samePercentCircleResult.dataset.title.replace( + /\se\s/, + ', ' + ) + + ' e ' + + data[i].name + samePercentCircleResult.setAttribute('data-title', newTitle) + continue + } + + // Block to max value as full or min height + if (y > Number(svgHeight)) { + y = Number(svgHeight) + } else if (y < 0) { + y = 0 + } + const circle = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'circle' + ) + circle.setAttribute('cx', '20') + circle.setAttribute('cy', String(y)) + circle.setAttribute('r', '6') + circle.setAttribute('fill', data[i].color) + if (data[i].label) { + circle.setAttribute('data-id', data[i].label) + } + circle.setAttribute('data-title', data[i].name) + circle.setAttribute('data-value', dataValue) + circle.setAttribute('opacity', '0.8') + circle.setAttribute('stroke', '#aaa') + circle.setAttribute('stroke-width', '0.4') + svg.appendChild(circle) + } + + svg.addEventListener( + 'mousemove', + (e) => { + const target = /** @type {SVGCircleElement} */ (e.target) + if (target && target.tagName === 'circle') { + let value + const dataValue = target.getAttribute('data-value') + try { + value = dataValue + ? JSON.parse(dataValue).value + : dataValue + } catch { + value = dataValue + } + const parentElement = /** @type {HTMLElement} */ ( + target.parentNode + ) + parentElement.appendChild(target) + showTooltip( + e, + String(target.getAttribute('data-title')), + value + ) + return + } + hideTooltip() + }, + false + ) + + svg.addEventListener('mouseleave', () => { + hideTooltip() + }) + } + + /** + * Exibe o tooltip na posição do mouse. + * @param {MouseEvent} evt - O evento de mouse (para capturar clientX e clientY). + * @param {string} text - O título ou texto principal. + * @param {string|number} value - O valor a ser exibido. + * @returns {void} + */ + const showTooltip = (evt, text, value) => { + const tooltip = + /** @type {HTMLElement} - Cast necessário para garantir acesso a .style */ ( + document.querySelector('.tooltip') + ) + + if (!tooltip) return + + tooltip.innerHTML = ` +
+
${text}
+
${value}
+
` + + tooltip.style.display = 'block' + tooltip.style.left = evt.clientX + 20 + 'px' + tooltip.style.top = evt.clientY - 30 + 'px' + } + + /** + * Esconde o tooltip alterando o display para none. + * @returns {void} + */ + const hideTooltip = () => { + const tooltip = /** @type {HTMLElement} */ ( + document.querySelector('.tooltip') + ) + + if (tooltip) { + tooltip.style.display = 'none' + } + } + + const getWindowWidth = () => { + // TODO: define a function to be called here + // handleMapChange(datasetValues.value); + } + + window.addEventListener('resize', getWindowWidth) + + // TODO: update code to work with new defined states vars instead of props + watch( + () => mapData.value, + () => { + if (mapData.value) { + datasetValues.value = mapData.value + handleMapChange(mapData.value) + } + } + ) + + watch( + () => mapTooltip.value, + () => { + const query = mapTooltip.value.label + ? `[data-id="${mapTooltip.value.label}"]` + : `[data-title="${mapTooltip.value.name}"]` + let allCircle = /** @type {any} **/ ([ + ...document.querySelectorAll('circle'), + ]) + let circle = document.querySelector(query) + ? document.querySelector(query) + : allCircle.find((/** @type {any} **/ item) => { + try { + const id = item.dataset.id.includes( + mapTooltip.value.label + ) + const name = item.dataset.title.includes( + mapTooltip.value.name + ) + return name || id + } catch { + // Do Nothing + } + }) + + if (!circle) { + return + } + + if (mapTooltip.value.opened) { + circle.setAttribute('r', '9') + circle.setAttribute('opacity', '1') + circle.setAttribute('stroke', '#7a7a7a') + return + } + + circle.setAttribute('r', '6') + circle.setAttribute('opacity', '0.8') + circle.setAttribute('stroke', '#aaa') + } + ) + + return { + mapRangeSVG, + maxVal, + minVal, + } + }, + template: ` + + {{ maxVal }} + + {{ minVal }} + +
+ `, +}) diff --git a/src/components/main/card-components/map/map.js b/src/components/main/card-components/map/map.js new file mode 100644 index 0000000..2295f6f --- /dev/null +++ b/src/components/main/card-components/map/map.js @@ -0,0 +1,57 @@ +import { defineComponent, onMounted, watch } from 'vue' + +import { NSpin } from 'naive-ui' + +import { useMapStore, useContentStore } from '@/stores/index' +import { storeToRefs } from 'pinia' + +export default defineComponent({ + components: { NSpin }, + setup() { + const mapStore = useMapStore() + const { mapElement } = storeToRefs(mapStore) + const contentStore = useContentStore() + const { form } = storeToRefs(contentStore) + + const updateSetMap = async () => { + await mapStore.updateMap() + await mapStore.setMapData() + } + + onMounted(async () => { + await updateSetMap() + }) + watch( + () => { + return [ + form.value.sickImmunizer, + form.value.dose, + form.value.type, + form.value.local, + form.value.granularity, + form.value.periodStart, + form.value.periodEnd, + ] + }, + async () => { + await updateSetMap() + } + ) + + watch( + () => form.value.period, + async (period) => { + mapStore.updatePeriodManual(period) + } + ) + + return { mapElement } + }, + template: ` +
+
+
+
+
+ `, +}) diff --git a/src/components/main/card-components/map/year-slider.js b/src/components/main/card-components/map/year-slider.js new file mode 100644 index 0000000..90ff09e --- /dev/null +++ b/src/components/main/card-components/map/year-slider.js @@ -0,0 +1,245 @@ +import { NCard, NSlider, NSpace, NButton, NIconWrapper, NIcon } from 'naive-ui' +import { defineComponent, ref, computed } from 'vue' +import { storeToRefs } from 'pinia' +import { useContentStore } from '@/stores/index' +import { biCaretDown } from '@/icons' + +export default defineComponent({ + components: { + NCard, + NSlider, + NSpace, + NButton, + NIconWrapper, + NIcon, + }, + setup() { + const contentStore = useContentStore() + const { form, tabBy, mandatoryVaccineYears, yearSlideAnimation } = + storeToRefs(contentStore) + + const showSlider = ref(false) + const showTooltip = ref(false) + const stopPlayMap = ref(false) + + /** + * @param {string | null} period + */ + const setSliderValue = (period) => { + showSlider.value = + form.value.periodStart && form.value.periodEnd ? true : false + if (period) { + return Number(period) + } + } + + const max = computed(() => setSliderValue(form.value.periodEnd)) + const min = computed(() => setSliderValue(form.value.periodStart)) + + /** + * @type {import('vue').Ref} + */ + const valueMandatoryLabels = ref(null) + + const valueMandatory = computed(() => { + if (tabBy.value !== 'immunizers') { + return + } + + const sickImmunizer = form.value.sickImmunizer + const dose = form.value.dose ? form.value.dose : '1ª dose' + + if (mandatoryVaccineYears.value) { + const result = mandatoryVaccineYears.value.find( + (/** @type{string[]} */ el) => + el[0] === sickImmunizer && + (el[1] === dose || + (el[1] === 'Dose única' && dose === '1ª dose')) + ) + if (result) { + valueMandatoryLabels.value = [result[2], result[3]] + if ( + max.value && + min.value && + ((max.value && max.value <= result[3]) || + (min.value && min.value >= result[2])) + ) { + return [result[2], result[3]] + } else if ( + max.value && + max.value <= result[3] && + max.value >= result[2] + ) { + return result[3] + } else if ( + min.value && + min.value >= result[2] && + min.value <= result[3] + ) { + return result[2] + } + } + } + + return + }) + + const years = computed(() => { + let y = min.value + const result = [] + + if (y && max.value) { + while (y <= max.value) { + result.push(y++) + } + } + + return result + }) + + const waitFor = (/** @type{number} */ delay) => + new Promise((resolve) => setTimeout(resolve, delay)) + + const playMap = async () => { + showTooltip.value = true + yearSlideAnimation.value = true + for (let year of years.value) { + if (stopPlayMap.value) { + stopPlayMap.value = false + return + } + form.value.period = year + await waitFor(1000) + } + showTooltip.value = false + yearSlideAnimation.value = false + stopPlayMap.value = false + } + + /** + * @param {string} key + * @param {string} value + * @returns void + */ + const updateDate = (key, value) => { + contentStore.setFormField(key, value) + } + return { + max, + min, + valueMandatory, + showSlider, + showTooltip, + formatTooltip: () => { + if (valueMandatoryLabels.value && valueMandatoryLabels.value) { + return `Presente no calendário vacinal entre ${valueMandatoryLabels.value[0]} e ${valueMandatoryLabels.value[1]}` + } + }, + playMap, + yearSlideAnimation, + stopMap: () => { + stopPlayMap.value = true + showTooltip.value = false + yearSlideAnimation.value = false + }, + biCaretDown, + updateDate, + form, + } + }, + template: ` +
+ + + + + + +
+
+ {{ min }} +
+ + + + + + +
+ {{ max }} +
+
+
+ `, +}) diff --git a/src/components/main/card-components/sub-buttons.js b/src/components/main/card-components/sub-buttons.js new file mode 100644 index 0000000..c7255d9 --- /dev/null +++ b/src/components/main/card-components/sub-buttons.js @@ -0,0 +1,557 @@ +import { NButton, NIcon, NCard, NScrollbar, NTabs, NTabPane } from 'naive-ui' +import { defineComponent, ref, computed } from 'vue' +import { storeToRefs } from 'pinia' +import { useContentStore, useMessageStore } from '@/stores' +import CanvasDownload from '@/canvas-download' + +//@ts-ignore +import CsvWriterGen from 'csvwritergen' + +import { formatToTable, formatDatePtBr } from '@/utils' + +import { + biBook, + biListUl, + biDownload, + biShareFill, + biFiletypeCsv, + biGraphUp, +} from '@/icons' + +import sbim from '@/assets/images/sbim.png' +import cc from '@/assets/images/cc.png' +import riAlertLine from '@/assets/images/ri-alert-line.svg' + +import ModalGeneric from '@/components/main/modal/modal-generic' +import ModalGenericWithTabs from '@/components/main/modal/modal-genetic-with-tabs' + +import Abandono from '@/assets/images/abandono.svg' +import Cobertura from '@/assets/images/cobertura.svg' +import HomGeo from '@/assets/images/hom_geo.svg' +import HomVac from '@/assets/images/hom_vac.svg' +import Meta from '@/assets/images/meta.svg' +import logo from '@/assets/images/logo-vacinabr.svg' + +export default defineComponent({ + components: { + NButton, + NCard, + NIcon, + NScrollbar, + NTabPane, + NTabs, + ModalGeneric, + ModalGenericWithTabs, + }, + setup() { + const messageStore = useMessageStore() + const contentStore = useContentStore() + + const { + csvAllDataLink, + csvRowsExceeded, + maxCsvExportRows, + selectsEmpty, + selectsPopulated, + aboutVaccines, + lastUpdateDate, + form, + tab, + mainTitle, + subTitle, + legend, + } = storeToRefs(contentStore) + + /** @type import('vue').Ref */ + const svg = ref(null) + /** @type import('vue').Ref */ + const chartPNG = ref(null) + /** @type import('vue').Ref */ + const chart = ref(null) + + /** @type import('vue').Ref */ + const showModal = ref(false) + /** @type import('vue').Ref */ + const showModalVac = ref(false) + /** @type import('vue').Ref */ + const loadingDownload = ref(false) + + const clickShowModal = () => { + const map = document.querySelector('#canvas') + svg.value = map?.innerHTML + const canvas = /** @type{any} */ (document.getElementById('chart')) + chartPNG.value = + canvas && ![...canvas.classList].includes('element-hidden') + ? canvas?.toDataURL('image/png', 1) + : null + showModal.value = true + } + + const aboutVaccinesContent = computed(() => { + const text = aboutVaccines.value + if (!text || !text.length) { + return + } + const div = document.createElement('div') + div.innerHTML = text[0].content.rendered + const result = [...div.querySelectorAll('table>tbody>tr')].map( + (tr) => { + return { + header: tr.querySelectorAll('td')[0].innerHTML, + content: tr.querySelectorAll('td')[1].innerHTML, + } + } + ) + return result + }) + + const clickShowVac = () => { + showModalVac.value = !showModalVac.value + } + + const copyCurrentLink = () => { + navigator.clipboard.writeText(window.location.href) + messageStore.message('success', 'Link copiado para o seu clipboard') + } + + const sendMail = () => { + document.location.href = + 'mailto:vacinabr@iqc.org.br?subject=Erro no VacinaBR&body=Sua Mensagem' + } + + const downloadCsv = async () => { + loadingDownload.value = true + const periodStart = form.value.periodStart + const periodEnd = form.value.periodEnd + let years = [] + if (periodStart && periodEnd) { + let y = Number(periodStart) + while (y <= Number(periodEnd)) { + years.push(y++) + } + } + + const currentResult = /** @type{any} */ ( + await contentStore.requestData({ detail: true, csv: true }) + ) + + if (currentResult && currentResult.aborted) { + return + } + if (currentResult && currentResult.error) { + loadingDownload.value = false + } + + if (!currentResult) { + messageStore.message( + 'error', + 'Preencha os seletores de filtro para gerar csv' + ) + loadingDownload.value = false + return + } + // GA Event + // @ts-ignore + if (window.gtag) { + // @ts-ignore + window.gtag('event', 'file_download', { + file_extension: 'csv', + link_text: 'Dados utilizados na interface em CSV', + file_name: 'tabela.csv', + }) + } + + const tableData = formatToTable( + currentResult.result.data, + currentResult.localNames, + currentResult.metadata + ) + + const header = tableData.header.map((x) => Object.values(x)[0]) + const type = form.value.type + header[header.findIndex((head) => head === 'Valor')] = type + const rows = tableData.rows.map((x) => Object.values(x)) + if (type == 'Doses aplicadas') { + const index = header.findIndex( + (column) => column === 'Doses (qtd)' + ) + header.splice(index, 1) + rows.forEach((row) => row.splice(index, 1)) + } + const csvwriter = new CsvWriterGen(header, rows) + csvwriter.anchorElement('tabela') + loadingDownload.value = false + } + + const goToCCLink = () => { + window.open('https://creativecommons.org/licenses/by/4.0/') + } + + const downloadPng = async () => { + // GA Event + // @ts-ignore + if (window.gtag) { + // @ts-ignore + window.gtag('event', 'file_download', { + file_extension: 'png', + link_text: 'Mapa PNG', + file_name: 'mapa.png', + }) + } + + if (!selectsPopulated.value) { + messageStore.message( + 'error', + 'Preencha os seletores de filtro para gerar mapa' + ) + return + } + + const svgElement = /** @type{Element} */ ( + document.querySelector('#canvas>svg') + ) + const svgContent = new XMLSerializer().serializeToString(svgElement) + + // Convert SVG content to a data URL + const svgBlob = new Blob([svgContent], { type: 'image/svg+xml' }) + const svgUrl = URL.createObjectURL(svgBlob) + + const images = [ + { image: svgUrl, height: 650, width: 650 }, + { image: logo, height: 53, width: 218, posX: 5, posY: 642 }, + ] + + const type = form.value.type + let legendSvg + + if (type === 'Abandono') { + legendSvg = Abandono + } else if (type === 'Cobertura') { + legendSvg = Cobertura + } else if (type === 'Homogeneidade geográfica') { + legendSvg = HomGeo + } else if (type === 'Homogeneidade entre vacinas') { + legendSvg = HomVac + } else if (type === 'Meta atingida') { + legendSvg = Meta + } + + if (legendSvg && tab.value === 'map') { + images.push({ + image: legendSvg, + width: 293, + height: 88, + posX: 1080, + posY: 622, + }) + } + const canvasDownload = new CanvasDownload(images, { + title: mainTitle.value, + subTitle: subTitle.value, + source: legend + '.', + }) + await canvasDownload.download() + } + + const downloadSvg = () => { + if (!selectsPopulated.value) { + messageStore.message( + 'error', + 'Preencha os seletores de filtro para gerar mapa' + ) + return + } + + // GA Event + // @ts-ignore + if (window.gtag) { + // @ts-ignore + window.gtag('event', 'file_download', { + file_extension: 'svg', + link_text: 'Mapa SVG', + file_name: 'mapa.svg', + }) + } + + const svgElement = /** @type{Element} */ ( + document.querySelector('#canvas') + ) + const svgData = svgElement.innerHTML + const svgBlob = new Blob([svgData], { + type: 'image/svg+xml;charset=utf-8', + }) + const svgUrl = URL.createObjectURL(svgBlob) + const downloadLink = document.createElement('a') + downloadLink.href = svgUrl + downloadLink.download = 'mapa.svg' + document.body.appendChild(downloadLink) + downloadLink.click() + document.body.removeChild(downloadLink) + } + + const downloadCsvAll = () => { + // GA Event + // @ts-ignore + if (window.gtag) { + // @ts-ignore + window.gtag('event', 'file_download', { + file_extension: 'zip', + link_text: 'Dados completos em CSV', + file_name: 'vacinabr.zip', + link_url: '/wp-content/uploads/vacinabr/vacinabr.zip', + }) + } + + // @ts-ignore + window.open(csvAllDataLink.value, '_blank') + } + + const downloadChartAsImage = async () => { + const imageLink = document.createElement('a') + imageLink.download = 'chart.png' + if (!chartPNG.value) { + messageStore.message( + 'error', + 'Preencha os seletores de filtro para gerar imagem' + ) + return + } + // GA Event + // @ts-ignore + if (window.gtag) { + // @ts-ignore + window.gtag('event', 'file_download', { + file_extension: 'png', + link_text: 'Chart PNG', + file_name: 'image.png', + }) + } + + const canvasDownload = new CanvasDownload( + [ + { image: chartPNG.value }, + { image: logo, height: 53, width: 218, posX: 5, posY: 842 }, + ], + { + title: mainTitle.value, + subTitle: subTitle.value, + source: legend.value + '.', + canvasHeight: 900, + yTextSource: 894, + } + ) + await canvasDownload.download() + } + + return { + aboutVaccinesContent, + biBook, + biDownload, + biFiletypeCsv, + biGraphUp, + biListUl, + biShareFill, + cc, + clickShowModal, + clickShowVac, + copyCurrentLink, + csvRowsExceeded, + downloadCsv, + downloadPng, + formatDatePtBr, + goToCCLink, + lastUpdateDate, + legend, + loadingDownload, + maxCsvExportRows, + riAlertLine, + sbim, + selectsEmpty, + sendMail, + showModal, + showModalVac, + svg, + tab, + downloadSvg, + downloadCsvAll, + downloadChartAsImage, + chartPNG, + } + }, + template: ` +
+ + + +
Faça o download de conteúdos
+
+ + +
+
Dados
+
+ +
+
+
+ +
+
+

Dados utilizados na interface em CSV

+

Os dados que estão sendo utilizados nesta interface

+
+
+ + +   Baixar + +
+
+ +
+
+
+ +
+
+

Dados completos em CSV

+

Todos os dados por município da plataforma vacinaBR

+
+
+ + +   Baixar + +
+
+
+
+
Licença:
+
+ +
+
+
+
+ `, +}) diff --git a/src/components/main/card-components/sub-select.js b/src/components/main/card-components/sub-select.js new file mode 100644 index 0000000..418867b --- /dev/null +++ b/src/components/main/card-components/sub-select.js @@ -0,0 +1,691 @@ +import { + defineComponent, + ref, + toRaw, + computed, + watch, + onMounted, + onBeforeUnmount, + nextTick, + h, +} from 'vue' + +import { + NSelect, + NFormItem, + NDatePicker, + NButton, + NIcon, + NSpin, + NSpace, + NTooltip, +} from 'naive-ui' + +import { storeToRefs } from 'pinia' +import { useContentStore } from '@/stores' +import { biEraser } from '@/icons' + +export default defineComponent({ + components: { + NButton, + NDatePicker, + NFormItem, + NIcon, + NSelect, + NSpace, + NSpin, + NTooltip, + }, + props: { + isMobileScreen: { + default: false, + type: Boolean, + }, + }, + setup(props) { + /** @type {any[]} */ + const allCitiesValues = [] + + const contentStore = useContentStore() + const { form, tab, tabBy, disableLocalSelect, yearSlideAnimation } = + storeToRefs(contentStore) + + /** @type import('vue').Ref */ + const activeSelectKey = ref({}) + /** @type import('vue').Ref */ + const citiesTemp = ref([]) + /** @type import('vue').Ref */ + const cityTemp = ref(null) + /** @type import('vue').Ref */ + const firstLoadCities = ref(true) + /** @type import('vue').Ref */ + const formRef = ref(null) + /** @type import('vue').Ref */ + const isLoadingCities = ref(false) + /** @type import('vue').Ref */ + const localTemp = ref(null) + /** @type import('vue').Ref */ + const resizeObserver = ref(null) + /** @type import('vue').Ref */ + const selectRefsMap = ref({}) + /** @type import('vue').Ref */ + const sickTemp = ref(null) + /** @type import('vue').Ref */ + const showingLocalsOptions = ref(null) + /** @type import('vue').Ref */ + const showingSicksOptions = ref(null) + + /** @type import('vue').Ref */ + const showCitiesSelect = ref(false) + + // Computed + const disableAll = computed(() => yearSlideAnimation.value) + + const styleWidth = computed(() => + props.isMobileScreen ? 'width: 400px;' : 'width: 200px;' + ) + + /** + * @param {string} key + * @param {string} value + * @returns void + */ + const updateDate = (key, value) => { + contentStore.setFormField(key, value) + updateDatePosition() + } + + const updateDatePosition = () => { + const formValue = form.value + const endDate = formValue.periodEnd + const startDate = formValue.periodStart + const tsEndDate = endDate + const tsStartDate = startDate + + if (!tsStartDate || !tsEndDate) { + return + } else if (tsStartDate > tsEndDate) { + contentStore.setFormField('periodEnd', startDate) + contentStore.setFormField('periodStart', endDate) + return + } + } + /** + * Select all states function + * @param {string} field + */ + const selectAllLocals = (field) => { + const formValue = form.value + const allOptions = toRaw(formValue.locals) + const selectLength = Array.isArray(localTemp.value) + ? localTemp.value.length + : null + if (selectLength == allOptions.length) { + localTemp.value = [] + handleShowUpdate(true, field) + return + } + + localTemp.value = allOptions.map((option) => option.value) + // handleShowUpdate(true, field) + } + /** + * @param {Boolean} show + * @param {String} key + */ + const handleShowUpdate = (show, key) => { + if (show) { + activeSelectKey.value = key + } else if (activeSelectKey.value === key) { + activeSelectKey.value = null + } + } + /** + * Select all cities function + * @param {String} field + * @param {Boolean} uncheckAll + */ + const selectAllCities = (field, uncheckAll = false) => { + const formValue = form.value + if (isLoadingCities.value) { + return + } + isLoadingCities.value = true + + // We use setTimeout to run this code after Vue render process + setTimeout(() => { + const allOptions = toRaw(citiesTemp.value) + const selectLength = Array.isArray(cityTemp.value) + ? cityTemp.value.length + : null + + if (selectLength === allOptions.length || uncheckAll) { + formValue.city = [] + cityTemp.value = [] + handleShowUpdate(true, field) + isLoadingCities.value = false + return + } + + formValue.city = allCitiesValues + cityTemp.value = allCitiesValues + + handleShowUpdate(true, field) + isLoadingCities.value = false + }, 0) + } + /** + * @param {Boolean} show + * @param {string} field + */ + const handleLocalsUpdateShow = (show, field) => { + showingLocalsOptions.value = show + if (!showingLocalsOptions.value && localTemp.value) { + contentStore.setFormField('local', localTemp.value) + } + handleShowUpdate(show, field) + } + /** + * @param {String} value + */ + const handleLocalsUpdateValue = (value) => { + localTemp.value = value + if (!showingLocalsOptions.value && localTemp.value) { + contentStore.setFormField('local', localTemp.value) + } + // Close hover box options remover - Mantido o comentário + } + /** + * @param {Boolean} show + * @param {String} field + */ + const handleSicksUpdateShow = (show, field) => { + showingSicksOptions.value = show + + if ( + !showingSicksOptions.value && + sickTemp.value && + tab.value !== 'map' + ) { + contentStore.setFormField('sickImmunizer', sickTemp.value) + } + handleShowUpdate(show, field) + } + /** + * @param {String} value + */ + const handleSicksUpdateValue = (value) => { + sickTemp.value = value + if (!showingSicksOptions.value && sickTemp.value) { + contentStore.setFormField('sickImmunizer', value) + } + // Close hover box options remover - Mantido o comentário + } + + const eraseForm = () => { + contentStore.clear() + } + + /** + * @param {String} key + */ + const clear = (key) => { + if (key === 'sickImmunizer') { + sickTemp.value = null + contentStore.setFormField('sickImmunizer', null) + } else if (key === 'dose') { + contentStore.setFormField('dose', null) + } else if (key === 'type') { + contentStore.setFormField('type', null) + } + } + + /** + * @param {Number} timeInMs + * @returns {Promise} + */ + const wait = (timeInMs) => { + return new Promise( + /** @param {function(): void} resolve */ + (resolve) => { + setTimeout(() => { + resolve() + }, timeInMs) + } + ) + } + + const showCitiesSelectUpdate = async () => { + await wait(100) + const granValue = form.value.granularity + if ( + granValue && + granValue.toLowerCase() === 'municípios' && + tab.value !== 'map' + ) { + showCitiesSelect.value = true + return + } + showCitiesSelect.value = false + } + + /** + * @param {String} value + */ + const disableStateCitiesSelector = (value) => { + const formValue = form.value + if (tab.value === 'table') { + formValue.city = value + if ( + formValue.cities && + formValue.cities.some((item) => item.disabled === true) + ) { + formValue.cities.forEach((item) => { + item.disabled = false + item.disabledText = '' + }) + } + return + } + + if (!value) { + return + } + + const valueLength = value.length + + const maxSelection = 30 + + if (valueLength <= maxSelection) { + formValue.city = value + if ( + formValue.cities && + formValue.cities.some((item) => item.disabled === true) + ) { + formValue.cities.forEach((item) => { + item.disabled = false + item.disabledText = '' + }) + } + if (valueLength === maxSelection) { + formValue.cities.forEach((item) => { + if (!value.includes(item.codigo6)) { + item.disabled = true + item.disabledText = 'Limite de seleções atingido' + } + }) + } + } + + if (valueLength > maxSelection) { + formValue.city = value.slice(0, maxSelection) + cityTemp.value = formValue.city + // store.commit("message/INFO", "Valores de seletor de municípios foram atualizado para limites de gráfico"); // Substituir por lógica de notificação Pinia/Naive-UI + } + } + + const handleCitiesUpdateValue = (/** @type String */ value) => { + disableStateCitiesSelector(value) + } + + /** + * @param {String} str + */ + const removeAccents = (str) => { + return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '') + } + + /** + * @param {String} pattern + * @param {{ label: string, value: string}} option + */ + const customFilter = (pattern, option) => { + const optionLabel = option.label || '' + const normalizedPattern = removeAccents(pattern).toLowerCase() + const normalizedLabel = removeAccents(optionLabel).toLowerCase() + + return normalizedLabel.includes(normalizedPattern) + } + + /** + * @type {( + * payload: { + * node: import('vue').VNode, + * option: { disabledText: string, disabled: boolean } + * } + * ) => import('vue').VNode | string} + */ + const renderOption = ({ node, option }) => { + if (!option.disabled) { + return node + } + return h( + NTooltip, + { + style: '', + delay: 500, + }, + { + trigger: () => node, + default: () => option.disabledText, + } + ) + } + + // Watchers + watch( + () => props.isMobileScreen, + async () => { + const loc = form.value.local + if (loc) { + citiesTemp.value = form.value.cities.filter((city) => + loc.includes(city.uf) + ) + } + await showCitiesSelectUpdate() + }, + { + deep: true, + immediate: true, + } + ) + + watch( + () => form.value.local, + (loc) => { + localTemp.value = loc + + isLoadingCities.value = true + + setTimeout(async () => { + if (!loc || !loc.length) { + citiesTemp.value = form.value.cities + cityTemp.value = [] + form.value.city = [] + } else { + const rawCities = toRaw(form.value.cities) + const locSet = new Set(loc) + + citiesTemp.value = rawCities.filter((city) => + locSet.has(city.uf) + ) + + if (form.value.city?.length) { + const citiesTempSet = new Set( + citiesTemp.value.map( + (/** @type {{ value:string }} */ item) => + item.value + ) + ) + + const rawCityValue = toRaw(form.value.city) + + form.value.city = rawCityValue.filter( + (/** @type {{ value:string }} */ itemA) => + citiesTempSet.has(itemA) + ) + cityTemp.value = form.value.city + } + + disableStateCitiesSelector(cityTemp.value) + } + + await showCitiesSelectUpdate() + isLoadingCities.value = false + }, 0) + }, + { deep: true } + ) + + watch( + () => form.value.cities, + (newCities) => { + if (!newCities) { + return + } + if (firstLoadCities.value) { + citiesTemp.value = newCities + firstLoadCities.value = false + for (let i = 0; i < newCities.length; i++) { + allCitiesValues.push(newCities[i].value) + } + } + } + ) + + watch( + () => tab.value, + async () => { + disableStateCitiesSelector(cityTemp.value) + await showCitiesSelectUpdate() + } + ) + + watch( + () => form.value.sickImmunizer, + (sic) => { + sickTemp.value = sic + } + ) + + watch( + () => form.value.granularity, + async () => { + await showCitiesSelectUpdate() + } + ) + + onMounted(async () => { + if (formRef.value) { + const mainContainer = formRef.value.closest('.main') + if (mainContainer) { + resizeObserver.value = new ResizeObserver( + updateDropdownPosition + ) + resizeObserver.value.observe(mainContainer) + } + } + // Update values if user is resizing window to mobile size + if (form.value.sickImmunizer) { + sickTemp.value = form.value.sickImmunizer + } + if (form.value.local) { + localTemp.value = form.value.local + } + if (form.value.city) { + cityTemp.value = form.value.city + } + }) + + onBeforeUnmount(() => { + sickTemp.value = form.value.sickImmunizer + localTemp.value = form.value.local + + if (resizeObserver.value) { + resizeObserver.value.disconnect() + } + }) + + const updateDropdownPosition = () => { + const key = activeSelectKey.value + + const selectedRef = selectRefsMap.value[key] + if (key && selectedRef) { + const activeSelect = selectedRef + activeSelect.blur() + nextTick(() => { + activeSelect.handleTriggerClick() + }) + } + } + + return { + biEraser, + citiesTemp, + cityTemp, + clear, + customFilter, + disableAll, + disableLocalSelect, + eraseForm, + form, + formRef, + handleCitiesUpdateValue, + handleLocalsUpdateShow, + handleLocalsUpdateValue, + handleShowUpdate, + handleSicksUpdateShow, + handleSicksUpdateValue, + isLoadingCities, + localTemp, + renderOption, + selectAllCities, + selectAllLocals, + selectRefsMap, + showCitiesSelect, + sickTemp, + styleWidth, + tab, + tabBy, + updateDatePosition, + contentStore, + updateDate, + } + }, + template: ` +
+ + + + + + + + + + + + + + + + + + +
+ + + +
+ + + + + +
+ `, +}) diff --git a/src/components/main/card-components/table.js b/src/components/main/card-components/table.js new file mode 100644 index 0000000..a560008 --- /dev/null +++ b/src/components/main/card-components/table.js @@ -0,0 +1,119 @@ +import { + defineComponent, + computed, + onBeforeMount, + onUnmounted, + watch, +} from 'vue' + +import { NButton, NDataTable, NEmpty, NSelect } from 'naive-ui' + +import { storeToRefs } from 'pinia' +import { useContentStore, useTableStore } from '@/stores' + +export default defineComponent({ + components: { + NButton, + NDataTable, + NEmpty, + NSelect, + }, + setup() { + const contentStore = useContentStore() + const { form, loading } = storeToRefs(contentStore) + + const formPopulated = computed(() => contentStore.selectsPopulated) + + const tableStore = useTableStore() + const { columns, page, pageCount, pageTotalItems, rows, sorter } = + storeToRefs(tableStore) + + const pagination = computed(() => ({ + page: page.value, + pageCount: pageCount.value, + pageSize: 10, + pageSlot: 7, + pageTotalItems: pageTotalItems.value, + simple: true, + prev: () => '🠐 anterior', + next: () => 'seguinte 🠒', + })) + + onBeforeMount(() => { + tableStore.setTableData() + }) + + onUnmounted(() => { + tableStore.resetState() + }) + + watch( + () => form.value, + async () => { + // Avoid render before change tab + if (Array.isArray(form.value.sickImmunizer)) { + page.value = 1 + await tableStore.setTableData() + } + }, + { deep: true } + ) + + /** + * @param {number} newPage + */ + const handlePageChange = async (newPage) => { + page.value = newPage + await tableStore.setTableData() + } + + /** + * @param {{ columnKey: string; order: string }} newSorter + */ + const handleSorterChange = async (newSorter) => { + sorter.value = newSorter + if (!newSorter.order) { + sorter.value = undefined + } + await tableStore.setTableData() + } + + return { + columns, + loading, + pagination, + rows, + handlePageChange, + handleSorterChange, + formPopulated, + } + }, + template: ` +
+ +
+ +
+
+
+ `, +}) diff --git a/src/components/main/card.js b/src/components/main/card.js new file mode 100644 index 0000000..c4c6790 --- /dev/null +++ b/src/components/main/card.js @@ -0,0 +1,127 @@ +import { defineComponent, onMounted, ref } from 'vue' +import { NCard, NButton, NIcon, NModal, NSkeleton, NSpin } from 'naive-ui' +import { useContentStore, useMapStore } from '@/stores' + +import { storeToRefs } from 'pinia' + +import Chart from '@/components/main/card-components/chart' +import FilterSuggestion from '@/components/main/card-components/filter-suggestion' +import Map from '@/components/main/card-components/map' +import SubSelect from '@/components/main/card-components/sub-select' +import Table from '@/components/main/card-components/table' +import SubButtons from '@/components/main/card-components/sub-buttons' + +export default defineComponent({ + components: { + Chart, + FilterSuggestion, + Map, + NButton, + NCard, + NIcon, + NModal, + NSkeleton, + NSpin, + SubButtons, + SubSelect, + Table, + }, + props: { api: { type: String, required: true } }, + setup() { + const isMobileScreen = ref(false) + const showModal = ref(false) + + const getWindowWidth = () => { + isMobileScreen.value = window.innerWidth <= 1368 + } + window.addEventListener('resize', getWindowWidth) + + const storeContent = useContentStore() + const { mainTitle, subTitle, loading, tab, selectsEmpty } = + storeToRefs(storeContent) + + const storeMap = useMapStore() + const { loadingMap } = storeToRefs(storeMap) + + onMounted(() => { + getWindowWidth() + }) + return { + isMobileScreen, + loading, + loadingMap, + mainTitle, + selectsEmpty, + showModal, + subTitle, + tab, + } + }, + template: ` +
+
+ +
+ +
+
+
+ + +
+

{{ mainTitle }}

+ +

{{ subTitle }}

+ +
+
+ + + +
+ +
+
+
+ +
+
+ `, +}) diff --git a/src/components/main/index.js b/src/components/main/index.js new file mode 100644 index 0000000..0e5eba2 --- /dev/null +++ b/src/components/main/index.js @@ -0,0 +1,158 @@ +import ModalCaller from '@/components/main/modal/modal-caller' +import MainCard from '@/components/main/card' +import { biMap, biGraphUp, biTable } from '@/icons' +import { computed, defineComponent, onBeforeMount, onMounted, watch } from 'vue' +import { storeToRefs } from 'pinia' +import { useContentStore } from '@/stores/content' +import { useMessageStore } from '@/stores/message' +import { + NButton, + NEmpty, + NIcon, + NScrollbar, + NSkeleton, + NTab, + NTabPane, + NTabs, + NTooltip, + useMessage, +} from 'naive-ui' + +export default defineComponent({ + components: { + NTabs, + NTabPane, + NTab, + NButton, + NIcon, + NScrollbar, + NTooltip, + NSkeleton, + NEmpty, + MainCard, + ModalCaller, + }, + props: { api: { type: String, required: true } }, + setup(props) { + const messageNaive = useMessage() + + const storeMessage = useMessageStore() + const { type, text, duration } = storeToRefs(storeMessage) + + const contentStore = useContentStore() + + const { + apiUrl, + disableChart, + disableMap, + tab, + tabBy, + yearSlideAnimation, + form, + } = storeToRefs(contentStore) + + const disableAll = computed(() => yearSlideAnimation.value) + + onBeforeMount(async () => { + apiUrl.value = props.api + contentStore.initial({ map: 'BR' }) + // Initialize filters, internal vars and modal contents + await Promise.all([ + contentStore.requestJson('dose-blocks'), + contentStore.requestJson('granularity-blocks'), + contentStore.requestJson('link-csv'), + contentStore.requestJson('mandatory-vaccinations-years'), + contentStore.requestJson('lastupdatedate'), + contentStore.requestJson('auto-filters'), + contentStore.requestJson('acronyms'), + contentStore.requestPage('slug=sobre-vacinas-vacinabr'), + ]) + }) + + onMounted(async () => { + await contentStore.updateFormSelect() + contentStore.setStateFromUrl() + }) + + // Update URL from state form and tabs changes + watch( + () => [ + form.value.dose, + form.value.granularity, + form.value.granularity, + form.value.local, + form.value.period, + form.value.periodEnd, + form.value.periodStart, + form.value.sickImmunizer, + form.value.type, + // TODO: define if cities will be in URL state + // form.city, + tab.value, + tabBy.value, + ], + () => { + contentStore.setUrlFromState() + } + ) + + // Show messages with useMessageStore state updates + storeMessage.$subscribe(() => { + if (text.value && type.value) { + messageNaive.create(text.value, { + type: type.value, + duration: duration.value ?? 3000, + }) + } + // Empty message state + storeMessage.clear() + }) + + return { + tabBy, + tab, + disableAll, + disableMap, + disableChart, + biMap, + biGraphUp, + biTable, + contentStore, + } + }, + template: ` +
+
+
+
+ + + + + +
+
+
+ + + + + Mapa + + + + Gráfico + + + + Tabela + + +
+
+
+ +
+ + `, +}) diff --git a/src/components/main/modal/index.js b/src/components/main/modal/index.js new file mode 100644 index 0000000..7378ed7 --- /dev/null +++ b/src/components/main/modal/index.js @@ -0,0 +1,51 @@ +import { NModal, NScrollbar } from 'naive-ui' +import { computed, defineComponent } from 'vue' + +export default defineComponent({ + components: { + NModal, + NScrollbar, + }, + props: { + show: { + type: Boolean, + }, + title: { + type: String, + }, + }, + setup(props, { emit }) { + const showModal = computed({ + get() { + return props.show + }, + set(value) { + emit('update:show', value) + }, + }) + return { + bodyStyle: { + maxWidth: '900px', + }, + showModal, + } + }, + template: ` + + +
+ +
+
+
+ `, +}) diff --git a/src/components/main/modal/modal-caller.js b/src/components/main/modal/modal-caller.js new file mode 100644 index 0000000..6cb5d65 --- /dev/null +++ b/src/components/main/modal/modal-caller.js @@ -0,0 +1,47 @@ +import ModalGeneric from '@/components/main/modal/modal-generic' +import ModalGenericWithTabs from '@/components/main/modal/modal-genetic-with-tabs' +import { computed, defineComponent } from 'vue' +import { storeToRefs } from 'pinia' +import { useModalStore } from '@/stores' + +export default defineComponent({ + components: { ModalGeneric, ModalGenericWithTabs }, + props: {}, + setup() { + const contentStore = useModalStore() + const { + genericModal, + genericModalLoading, + genericModalShow, + genericModalTitle, + } = storeToRefs(contentStore) + + const loading = computed(() => genericModalLoading.value) + const title = computed(() => genericModalTitle.value) + const modalContent = computed(() => { + const text = genericModal.value + if (!text || !text.length) { + return + } + const div = document.createElement('div') + div.innerHTML = text[0].content.rendered + + if (div.querySelector('table')) { + const trs = div.querySelectorAll('table>tbody>tr') + const result = Array.from(trs).map((tr) => ({ + header: tr.querySelectorAll('td')[0].innerHTML, + content: tr.querySelectorAll('td')[1].innerHTML, + })) + return result + } + + return text[0].content.rendered + }) + + return { loading, modalContent, genericModalShow, title } + }, + template: ` + + + `, +}) diff --git a/src/components/main/modal/modal-generic.js b/src/components/main/modal/modal-generic.js new file mode 100644 index 0000000..6893209 --- /dev/null +++ b/src/components/main/modal/modal-generic.js @@ -0,0 +1,76 @@ +import Modal from '@/components/main/modal' +import { NEmpty, NScrollbar, NSkeleton } from 'naive-ui' +import { useSlots, computed, defineComponent } from 'vue' + +export default defineComponent({ + components: { + Modal, + NScrollbar, + NSkeleton, + NEmpty, + }, + props: { + show: { type: Boolean }, + loading: { type: Boolean }, + title: { type: String }, + modalContent: { type: String }, + }, + setup(props, { emit }) { + const slots = useSlots() + + const showModal = computed({ + get() { + return props.show + }, + set(value) { + emit('update:show', value) + }, + }) + + const hasContent = computed(() => { + const defaultSlot = slots.default ? slots.default() : [] + return defaultSlot.length > 0 + }) + + return { showModal, hasContent } + }, + template: ` + + + + + + + `, +}) diff --git a/src/components/main/modal/modal-genetic-with-tabs.js b/src/components/main/modal/modal-genetic-with-tabs.js new file mode 100644 index 0000000..5ff6d86 --- /dev/null +++ b/src/components/main/modal/modal-genetic-with-tabs.js @@ -0,0 +1,63 @@ +import { computed, defineComponent } from 'vue' + +import { NModal, NScrollbar, NTabs, NTabPane, NSpin } from 'naive-ui' + +export default defineComponent({ + components: { + NModal, + NScrollbar, + NTabs, + NTabPane, + NSpin, + }, + props: { + modalContent: { type: Array }, + show: { type: Boolean }, + title: { type: String }, + }, + setup(props, { emit }) { + const showModal = computed({ + get() { + return props.show + }, + set(value) { + emit('update:show', value) + }, + }) + + return { + bodyStyle: { maxWidth: '900px' }, + showModal, + items: computed(() => props.modalContent), + } + }, + template: ` + +
+ + + +
+
+
+
+
+ +
+
+
+ `, +}) diff --git a/src/data-fetcher.js b/src/data-fetcher.js new file mode 100644 index 0000000..49c1e6b --- /dev/null +++ b/src/data-fetcher.js @@ -0,0 +1,150 @@ +/** + * @typedef {{ + * signal?: AbortSignal, + * body?: object, + * method?: string, + * headers?: Object. + * }} FetchOptions + */ + +/** + * Utility class for fetching data from an API, formatted for WordPress-style endpoints. + * @export + */ +export class DataFetcher { + /** + * Creates an instance of DataFetcher. + * @param {string} api - The base URL of the API (e.g., 'https://example.com'). + */ + constructor(api) { + /** @type {string} */ + this.api = api + } + + /** + * Fetches data using a GET request. + * Designed for simple requests where parameters are in the URL. + * + * @async + * @param {string} endPoint - The specific API endpoint to fetch (e.g., 'posts'). + * @param {string} [apiPoint="/wp-json/api/v1/"] - The API path prefix. + * @param {AbortSignal} [signal] - An optional AbortSignal to cancel the request. + * @returns {Promise<{ [key: string]: any, error?: Error, aborted?: boolean }>} + * an abort object, or an error object. + */ + async requestData(endPoint, apiPoint = '/wp-json/api/v1/', signal) { + try { + const url = this.api + apiPoint + endPoint + const options = signal ? { signal } : undefined + const response = await fetch(url, options) + + const data = await response.json() + return data + } catch (error) { + if (error instanceof Error) { + if (error.name === 'AbortError') { + return { aborted: true } + } + return { error } + } + return { error: new Error(String(error)) } + } + } + + // TODO: Maybe we will need to do more complex filters with args in request body + /** + * Fetches data with advanced options, allowing for custom methods, headers, and a request body. + * + * @async + * @param {string} endPoint - The specific API endpoint. + * @param {FetchOptions} [options={}] - Fetch options including signal, body, method, and headers. + * @param {string} [apiPoint="/wp-json/api/v1/"] - The API path prefix. + * @returns {Promise} + * This can be a JSON object, raw text, an abort object, or an Error. + */ + async requestDataInBody( + endPoint, + options = {}, + apiPoint = '/wp-json/api/v1/' + ) { + const { + signal, + body, + method = 'GET', + headers: customHeaders = {}, + } = options + + console.log({ api: this.api, apiPoint, endPoint }) + const url = this.api + apiPoint + endPoint + + /** @type {Object.} */ + const fetchOptions = { + method, + signal, + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...customHeaders, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + } + + try { + // Remove keys with undefined values + Object.keys(fetchOptions).forEach( + (key) => + fetchOptions[key] === undefined && delete fetchOptions[key] + ) + + const response = await fetch(url, fetchOptions) + + const contentType = response.headers.get('content-type') + if (contentType && contentType.includes('application/json')) { + return await response.json() + } + + return response.text() + } catch (error) { + console.log(error) + if (error instanceof Error) { + if (error.name === 'AbortError') { + return { aborted: true } + } + return error + } + return new Error(String(error)) + } + } + + /** + * Simplified GET request using the default API path ("/wp-json/api/v1/"). + * + * @async + * @param {string} endPoint - The specific API endpoint. + * @param {AbortSignal} [signal] - An optional AbortSignal. + * @returns {Promise<{ [key: string]: any, error?: Error, aborted?: boolean }>} + * @see {@link DataFetcher#requestData} + */ + async request(endPoint, signal = undefined) { + const result = await this.requestData( + endPoint, + '/wp-json/api/v1/', + signal + ) + return result + } + + /** + * GET request that allows specifying a custom API path. + * + * @async + * @param {string} endPoint - The specific API endpoint. + * @param {string} apiEndpoint - The custom API path prefix (e.g., '/wp-json/v2/'). + * @param {AbortSignal} [signal] - An optional AbortSignal. + * @returns {Promise<{ [key: string]: any, error?: Error, aborted?: boolean }>} + * @see {@link DataFetcher#requestData} + */ + async requestSettingApiEndPoint(endPoint, apiEndpoint, signal) { + const result = await this.requestData(endPoint, apiEndpoint, signal) + return result + } +} diff --git a/src/icons.js b/src/icons.js new file mode 100644 index 0000000..a9229a5 --- /dev/null +++ b/src/icons.js @@ -0,0 +1,65 @@ +/** + * Generates an SVG tag for use in HTML. + * + * @param {string} path - The SVG path data. + * @returns {string} The formatted SVG tag. + */ +const svgTag = (path) => { + return ` + ${path} + ` +} + +export const biBook = svgTag( + `` +) + +export const biListUl = svgTag( + `` +) + +export const biDownload = svgTag( + `` +) + +export const biShareFill = svgTag( + `` +) + +export const biFiletypeCsv = svgTag( + `` +) + +export const biGraphUp = svgTag( + `` +) + +export const biInfoCircle = svgTag( + `` +) + +export const biConeStriped = svgTag( + `` +) + +export const biEraser = svgTag( + `` +) + +export const biCaretDown = svgTag( + `` +) + +export const biMap = svgTag( + `` +) + +export const biTable = svgTag( + `` +) diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..d14f5c4 --- /dev/null +++ b/src/main.js @@ -0,0 +1,122 @@ +import '@/assets/css/style.css' +import Config from '@/components/config' +import Main from '@/components/main' +import router from '@/router' + +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import { storeToRefs } from 'pinia' +import { useModalStore, useContentStore } from '@/stores' + +/** + * @file Manages the rendering of a map, chart and table component. + * @module MapChartTable + */ + +/** + * Represents a component for rendering a map, chart, and table. + * @class MapChartTable + */ +class MapChartTable { + self = this + + /** + * A function that opens a generic modal with a title and slug, handling loading states and fetching content. + * @type {((title: string, slug: string) => Promise) | undefined} + */ + genericModal + + /** + * A function that handles opening a generic modal with title and slug, manages loading states, and fetches content. + * @type {((title: string, slug: string) => Promise) | undefined} + */ + genericModalWithFilterButton + + /** + * The API endpoint URL. + * @type {string} + */ + api + + /** + * The base address for resources or other URLs. + * @type {string} + */ + baseAddress + + /** + * Creates an instance of MapChartTable. + * @constructor + * @param {object} config - Configuration object for the component. + * @param {string} config.api - The API endpoint URL. + * @param {string} [config.baseAddress=''] - The base address, defaults to an empty string if not provided. + */ + + /** + * Constructs a new instance of the class. + * @param {Object} options - The configuration options for the instance. + * @param {string} options.api - The API address to be used by the instance. + * @param {string} [options.baseAddress=''] - The base address for requests, defaults to an empty string. + */ + constructor({ api = '', baseAddress = '' }) { + this.api = api + this.baseAddress = baseAddress + this.render() + } + + /** + * Renders the component, setting up Vue and Pinia applications to include Config and Main components. + * @returns {void} + */ + render() { + const self = this + const App = { + components: { Config, Main }, + setup() { + const modalStore = useModalStore() + const { + genericModal, + genericModalShow, + genericModalTitle, + genericModalLoading, + } = storeToRefs(modalStore) + + const contentStore = useContentStore() + const { extraFilterButton } = storeToRefs(contentStore) + + self.genericModal = async (title, slug) => { + genericModalLoading.value = true + genericModal.value = null + genericModalShow.value = !genericModalShow.value + genericModalTitle.value = title + try { + await modalStore.requestContent(slug) + } catch { + // Do Nothing + } + genericModalLoading.value = false + } + + self.genericModalWithFilterButton = async (title, slug) => { + extraFilterButton.value = { title, slug } + } + + return { api: self.api } + }, + template: ` + +
+ + `, + } + + const pinia = createPinia() + const app = createApp(App) + + app.use(pinia) + app.use(router(this.baseAddress)) + app.mount('#app') + } +} + +export default MapChartTable diff --git a/src/map-chart.js b/src/map-chart.js new file mode 100644 index 0000000..5d00e3b --- /dev/null +++ b/src/map-chart.js @@ -0,0 +1,759 @@ +import Abandono from '@/assets/images/abandono.svg' +import Cobertura from '@/assets/images/cobertura.svg' +import HomGeo from '@/assets/images/hom_geo.svg' +import HomVac from '@/assets/images/hom_vac.svg' +import Meta from '@/assets/images/meta.svg' +/** + * @typedef {Object} DatasetItem + * @property {string|number} value - Valor principal + * @property {string} [name] - Nome + * @property {string} [label] - Sigla/Label + * @property {string} [color] - Cor Hex ou RGB + * @property {number} [population] - População (usado no tooltip) + * @property {number} [doses] - Doses (usado no tooltip) + * @property {string|number} [id] - ID opcional + */ + +/** + * @typedef {Object} MapChartOptions + * @property {HTMLElement} element + * @property {string} [map] + * @property {Object.} [datasetCities] + * @property {Array>} [cities] + * @property {Object.} [datasetStates] + * @property {Array>} [states] + * @property {string[]} [statesSelected] + * @property {function(boolean, string, string|number): void} [tooltipAction] + * @property {string} [type] + * @property {boolean} [formPopulated] + * @property {boolean} [loading] + */ + +export class MapChart { + /** + * @param {MapChartOptions} options + */ + constructor({ + element, + map, + datasetCities, + cities, + datasetStates, + states, + statesSelected, + tooltipAction, + type, + formPopulated, + loading, + }) { + /** @type {HTMLElement} */ + this.element = + typeof element === 'string' + ? // @ts-ignore + element.querySelector(element) + : element + + /** @type {string | undefined} */ + this.map = map + /** @type {Object. | undefined} */ + this.datasetCities = datasetCities + /** @type {Array> | undefined} */ + this.cities = cities + /** @type {Object. | undefined} */ + this.datasetStates = datasetStates + /** @type {Array> | undefined} */ + this.states = states + /** @type {string[] | undefined} */ + this.statesSelected = statesSelected + /** @type {((opened: boolean, name: string, id: string|number) => void) | undefined} */ + this.tooltipAction = tooltipAction + /** @type {string | undefined} */ + this.type = type + /** @type {boolean | undefined} */ + this.formPopulated = formPopulated + + this.loading = loading + + /** @type {Array} */ + this.datasetValues = [] + + this.start() + } + + start() { + const self = this + + if (!self.element) { + return + } + + if (self.datasetCities) { + self.render() + self.loadMapState() + return + } + + self.render() + self.loadMapNation() + } + + /** + * @param {MapChartOptions} params + */ + update({ + element, + map, + datasetCities, + cities, + datasetStates, + states, + statesSelected, + type, + formPopulated, + loading, + }) { + const self = this + self.loading = loading + self.element = element + if (!self.element) { + return + } + + self.map = map ?? self.map + self.cities = cities ?? self.cities + self.states = states ?? self.states + self.statesSelected = statesSelected ?? self.statesSelected + + self.datasetCities = datasetCities + self.datasetStates = datasetStates + self.type = type + self.formPopulated = formPopulated + + self.start() + } + + /** + * @param {string} [map] + */ + applyMap(map) { + const self = this + + const svgContainer = /** @type {HTMLElement} */ ( + self.element.querySelector('#canvas') + ) + svgContainer.innerHTML = map ?? '' + for (const path of svgContainer.querySelectorAll('path')) { + path.style.stroke = 'white' + path.setAttribute('stroke-width', '1px') + path.setAttribute('vector-effect', 'non-scaling-stroke') + } + + const svgElement = /** @type {SVGElement} */ ( + svgContainer.querySelector('svg') + ) + if (svgElement) { + svgElement.style.maxWidth = '100%' + svgElement.style.height = '100%' + svgElement.style.margin = 'auto' + } + } + + /** + * @param {any} contentData + * @param {string|number} elementId + * @returns {[number, number, number, (string|number)[] | undefined]} + */ + getData(contentData, elementId) { + if (!contentData) { + // @ts-ignore + return [] + } + const index = + contentData[0].indexOf('geom_id') != -1 + ? contentData[0].indexOf('geom_id') + : contentData[0].indexOf('id') + const indexName = contentData[0].indexOf('name') + const indexAcronym = contentData[0].indexOf('acronym') + + return [ + index, + indexName, + indexAcronym, + contentData.find( + (/** @type {string[]} */ el) => el[index] === elementId + ), + ] + } + + /** + * @param {Object} params + * @param {Array} [params.datasetStates] + * @param {Array>} [params.contentData] + * @param {string} [params.type] + */ + setData({ datasetStates, contentData, type } = {}) { + const self = this + + // Querying map country states setting eventListener + for (const element of self.element.querySelectorAll( + '#canvas path, #canvas g' + )) { + let elementId = element.id + + if (elementId.length > 6) { + // granularity Municipios + elementId = element.id.substring(0, elementId.length - 1) + } + + // @ts-ignore + let [index, indexName, indexAcronym, currentElement] = self.getData( + contentData, + elementId + ) + + let content + if (currentElement) { + content = currentElement + } + + if (!content || !content[indexName]) { + continue + } + + /** @type {DatasetItem} */ + let dataset = { value: '---', color: '#D3D3D3' } + /** @type {any} */ + let datasetValuesFound = [] + + // @ts-ignore: Acesso dinâmico a propriedade 'id' em array genérico + if (content.id) { + // Get by id + // @ts-ignore + datasetValuesFound = self.datasetValues.find( + (ds) => + ds.name == content[indexName] && + ds.id == content[indexName] + ) + } else { + // Get by name + datasetValuesFound = self.datasetValues.find( + (ds) => + ds.name == content[indexName] && + ds.name == content[indexName] + ) + } + + if (datasetValuesFound) { + dataset = datasetValuesFound + } + + // @ts-ignore + const result = dataset.data || dataset + const resultColor = dataset.color + const tooltip = /** @type {HTMLElement} */ ( + self.element.querySelector('.mct-tooltip') + ) + + // @ts-ignore + const htmlElement = /** @type {HTMLElement} */ (element) + + htmlElement.addEventListener('mousemove', (event) => { + self.tooltipPosition(event, tooltip) + }) + htmlElement.addEventListener('mouseover', (event) => { + const target = /** @type {HTMLElement} */ (event.target) + target.style.strokeWidth = '2px' + let tooltipExtra = '' + if (result && result.population) { + tooltipExtra = ` + População alvo +
${result.population.toLocaleString('pt-BR')}
+ ` + + if (type !== 'Doses aplicadas' && result.doses) { + tooltipExtra += ` + Doses aplicadas +
${result.doses.toLocaleString('pt-BR')}
+ ` + } + } + let value = result.value + if (type === 'Meta atingida') { + if (result.value !== '---') { + // @ts-ignore: parseInt em string|number + value = parseInt(result.value) === 0 ? 'Não' : 'Sim' + } + } + tooltip.innerHTML = ` +
+
${content[indexName]}
+
${value}
+ ${tooltipExtra} +
` + tooltip.style.display = 'block' + // @ts-ignore: includes em number|string + tooltip.style.backgroundColor = result.value + .toString() + .includes('---') + ? 'grey' + : 'var(--primary-color)' + self.tooltipPosition(event, tooltip) + self.runTooltipAction(true, content[indexName], content[index]) + }) + htmlElement.addEventListener('mouseleave', (event) => { + const target = /** @type {HTMLElement} */ (event.target) + if (target.tagName === 'g') { + ;[...target.querySelectorAll('path')].forEach( + (path) => (path.style.strokeWidth = '1px') + ) + } else { + htmlElement.style.strokeWidth = '1px' + } + + htmlElement.style.fill = resultColor || '' + htmlElement.style.stroke = 'white' + tooltip.style.display = 'none' + self.runTooltipAction(false, content[indexName], content[index]) + }) + + htmlElement.style.fill = resultColor || '' + } + + // dataResult is undefined if nothing is comming from API and selects is setted + if ( + self.formPopulated && + (!datasetStates || !datasetStates.length) && + !this.loading + ) { + const span = self.element.querySelector('.empty-message span') + if (span) { + span.innerHTML = + 'Não existem dados para os filtros selecionados' + } + + const emptyMessage = /** @type {HTMLElement} */ ( + self.element.querySelector('.empty-message') + ) + if (emptyMessage) { + emptyMessage.style.display = 'block' + } + } else if ( + !self.formPopulated && + (!datasetStates || !datasetStates.length) && + !this.loading + ) { + const span = self.element.querySelector('.empty-message span') + if (span) { + span.innerHTML = + 'Selecione os filtros desejados para iniciar a visualização dos dados' + } + + const emptyMessage = /** @type {HTMLElement} */ ( + self.element.querySelector('.empty-message') + ) + if (emptyMessage) { + emptyMessage.style.display = 'block' + } + } else { + const emptyMessage = /** @type {HTMLElement} */ ( + self.element.querySelector('.empty-message') + ) + if (emptyMessage) { + emptyMessage.style.display = 'none' + } + } + } + + /** + * @param {boolean} opened + * @param {string} name + * @param {string|number} id + */ + runTooltipAction(opened, name, id) { + if (!this.tooltipAction) { + return + } + this.tooltipAction(opened, name, id) + } + + /** + * @param {MouseEvent} event + * @param {HTMLElement} tooltip + */ + tooltipPosition(event, tooltip) { + tooltip.style.left = event.clientX + 20 + 'px' + tooltip.style.top = event.clientY + 20 + 'px' + } + + /** + * @param {Array} arr + * @param {any} name + */ + findElement(arr, name) { + for (let i = 0; i < arr.length; i++) { + const object = arr[i] + const labelLowerCase = object.label.toLowerCase() + + if (!name) { + continue + } + + const nameAcronymLowerCase = name.acronym + ? name.acronym.toLowerCase() + : '' + const nameNameLowerCase = name.name ? name.name.toLowerCase() : '' + + const labelWithoutSpaces = labelLowerCase.replaceAll(' ', '') + + if ( + labelLowerCase == nameAcronymLowerCase || + labelWithoutSpaces == nameNameLowerCase.replaceAll(' ', '') || + labelLowerCase == nameNameLowerCase || + labelWithoutSpaces == nameNameLowerCase.replaceAll(' ', '') + ) { + return object + } + } + + return + } + + /** + * @param {number} maxVal + * @param {number} minVal + * @param {number} val + */ + getPercentage(maxVal, minVal, val) { + return ((val - minVal) / (maxVal - minVal)) * 100 + } + + /** + * @param {Object.} dataset + */ + getMaxAndMinValues(dataset) { + // @ts-ignore: includes em number|string + if (Object.values(dataset)[0].value.toString().includes('%')) { + return + } + + const values = Object.values(dataset).map((val) => + val.value.toString().replace(/[,.]/g, '') + ) + // @ts-ignore: Spread em string[] + const maxVal = Math.max(...values) + // @ts-ignore: Spread em string[] + const minVal = Math.min(...values) + return { maxVal, minVal } + } + + getMaxColorVal() { + const self = this + if (self.type === 'Cobertura') { + return 120 + } + + return 100 + } + + loadMapState() { + const self = this + /** @type {Array} */ + let result = [] + + if (self.datasetCities) { + const resultValues = self.getMaxAndMinValues(self.datasetCities) + result = Object.entries(self.datasetCities).map(([key, val]) => { + let color = resultValues + ? self.getPercentage( + resultValues.maxVal, + resultValues.minVal, + // @ts-ignore + val.value.toString().replace(/[,.]/g, '') + ) + : parseFloat(val.value.toString()) + + // @ts-ignore + let [index, indexName, indexAcronym, currentElement] = + self.getData(self.cities, key) + + if (!currentElement) { + return + } + + const name = currentElement[indexName] + const label = currentElement[indexAcronym] + + /** @type {DatasetItem} */ + const contentData = { + label: String(label), + // @ts-ignore + data: val, + name: String(name), + color: self.getColor( + color, + self.getMaxColorVal(), + self.type + ), + } + // @ts-ignore + const id = currentElement.id + if (id) { + contentData['id'] = id + } + + return contentData + }) + } + + self.datasetValues = result + self.applyMap(self.map) + + self.setData({ + datasetStates: result, + contentData: self.cities, + type: self.type, + }) + } + + loadMapNation() { + const self = this + /** @type {Array} */ + let result = [] + + if (self.datasetStates) { + const resultValues = self.getMaxAndMinValues(self.datasetStates) + result = Object.entries(self.datasetStates) + .map(([key, val]) => { + let color = resultValues + ? self.getPercentage( + // @ts-ignore + resultValues.maxVal, + resultValues.minVal, + // @ts-ignore + val.value.toString().replace(/[,.]/g, '') + ) + : parseFloat(val.value.toString()) + + // @ts-ignore + let [index, indexName, indexAcronym, currentElement] = + self.getData(self.states, key) + + if (!currentElement) { + return + } + + const name = currentElement[indexName] + const label = currentElement[indexAcronym] + + /** @type {DatasetItem} */ + const contentData = { + label: String(label), + // @ts-ignore + data: val, + name: String(name), + color: self.getColor( + color, + self.getMaxColorVal(), + self.type + ), + } + // @ts-ignore + const id = currentElement.id + if (id) { + contentData['id'] = id + } + + return contentData + }) + .filter((content) => { + if (content && self.statesSelected) { + // @ts-ignore + return self.statesSelected.includes(content.label) + } + }) + } + + self.datasetValues = result + + self.applyMap(self.map) + + self.setData({ + datasetStates: result, + contentData: self.states, + type: self.type, + }) + } + + /** + * @param {number} percentage + * @param {number} [maxVal] + * @param {string} [type] + * @param {boolean} [reverse] + */ + getColor(percentage, maxVal = 100, type, reverse = false) { + const cPalette0 = [ + 'rgb(0, 69, 124)', + 'rgb(0, 92, 161)', + 'rgb(50, 161, 230)', + 'rgb(246, 194, 188)', + 'rgb(207, 84, 67)', + 'rgb(105, 42, 34)', + ] + + if (type === 'Abandono') { + if (percentage <= -5) { + return cPalette0[0] + } else if (percentage > -5 && percentage <= 0) { + return cPalette0[1] + } else if (percentage > 0 && percentage <= 5) { + return cPalette0[2] + } else if (percentage > 5 && percentage <= 10) { + return cPalette0[3] + } else if (percentage > 10 && percentage <= 50) { + return cPalette0[4] + } else { + // percentage > 50 + return cPalette0[5] + } + } else if (type === 'Cobertura') { + if (percentage <= 50) { + return cPalette0[5] + } else if (percentage > 50 && percentage <= 80) { + return cPalette0[4] + } else if (percentage > 80 && percentage <= 95) { + return cPalette0[3] + } else if (percentage > 95 && percentage <= 100) { + return cPalette0[2] + } else if (percentage > 100 && percentage <= 120) { + return cPalette0[1] + } else { + // percentage > 120 + return cPalette0[0] + } + } else if (type === 'Homogeneidade geográfica') { + if (percentage <= 20) { + return cPalette0[5] + } else if (percentage > 20 && percentage <= 50) { + return cPalette0[4] + } else if (percentage > 50 && percentage <= 70) { + return cPalette0[3] + } else if (percentage > 70 && percentage <= 95) { + return cPalette0[2] + } else { + // percentage > 95 + return cPalette0[0] + } + } else if (type === 'Homogeneidade entre vacinas') { + if (percentage <= 20) { + return cPalette0[0] + } else if (percentage > 20 && percentage <= 40) { + return cPalette0[1] + } else if (percentage > 40 && percentage <= 60) { + return cPalette0[2] + } else if (percentage > 60 && percentage <= 80) { + return cPalette0[3] + } else { + // percentage > 80 + return cPalette0[4] + } + } else if (type === 'Meta atingida') { + if (percentage == 0) { + return cPalette0[4] + } else { + return cPalette0[2] + } + } + + const cPalette = [ + { r: 156, g: 63, b: 51 }, + { r: 207, g: 84, b: 67 }, + { r: 231, g: 94, b: 75 }, + { r: 234, g: 114, b: 98 }, + { r: 237, g: 134, b: 120 }, + { r: 243, g: 174, b: 165 }, + { r: 246, g: 194, b: 188 }, + { r: 160, g: 209, b: 242 }, + { r: 50, g: 161, b: 230 }, + { r: 1, g: 121, b: 218 }, + { r: 1, g: 111, b: 196 }, + { r: 0, g: 92, b: 161 }, + ] + + const colors = reverse ? cPalette.reverse() : cPalette + + if (!percentage) { + percentage = 0 + } else if (percentage < 0) { + return reverse ? 'rgb(0, 69, 124)' : 'rgb(105, 42, 34)' + } else if (percentage > maxVal) { + return reverse ? 'rgb(0, 69, 124)' : 'rgb(0, 69, 124)' + } + + const index = Math.floor((percentage / maxVal) * (colors.length - 1)) + + const lowerColor = colors[index] + const upperColor = + index < colors.length - 1 ? colors[index + 1] : colors[index] + const factor = (percentage / maxVal) * (colors.length - 1) - index + const interpolatedColor = { + r: Math.round( + lowerColor.r + (upperColor.r - lowerColor.r) * factor + ), + g: Math.round( + lowerColor.g + (upperColor.g - lowerColor.g) * factor + ), + b: Math.round( + lowerColor.b + (upperColor.b - lowerColor.b) * factor + ), + } + + return `rgb(${interpolatedColor.r}, ${interpolatedColor.g}, ${interpolatedColor.b})` + } + + render() { + const self = this + + let legend = '' + let legendSvg = '' + + if (self.type === 'Abandono') { + legendSvg = Abandono + } else if (self.type === 'Cobertura') { + legendSvg = Cobertura + } else if (self.type === 'Homogeneidade geográfica') { + legendSvg = HomGeo + } else if (self.type === 'Homogeneidade entre vacinas') { + legendSvg = HomVac + } else if (self.type === 'Meta atingida') { + legendSvg = Meta + } + + if (legendSvg) { + legend = `map file` + } + + const emptyIcon = ` +
+ ` + + const map = ` +
+
+
+
+ ${legend} +
+
+ +
+
+ ` + + if (self.element) { + self.element.innerHTML = map + } + } +} diff --git a/src/router/index.js b/src/router/index.js new file mode 100644 index 0000000..3db1b1f --- /dev/null +++ b/src/router/index.js @@ -0,0 +1,30 @@ +import { createRouter, createWebHistory } from 'vue-router' +import Main from '@/components/main' + +/** @type {import('vue-router').Router|null} */ +let routerInstance = null + +/** + * Create new incence fo Vue Router with dynamic setup + * + * @param {string} baseAddress - base path to routes (ex: '/'). + * @returns {import('vue-router').Router} An instance of VueRouter. + */ +export default (baseAddress) => { + const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: baseAddress, + name: 'main', + component: Main, + }, + ], + }) + + routerInstance = router + + return router +} + +export const getRouter = () => routerInstance diff --git a/src/stores/chart.js b/src/stores/chart.js new file mode 100644 index 0000000..1340a18 --- /dev/null +++ b/src/stores/chart.js @@ -0,0 +1,200 @@ +import { defineStore } from 'pinia' +import { useContentStore } from '@/stores/content' + +/** + * @typedef {Object} ApiResponseChart + * @property {string[]} localNames - Array of local names. + * @property {{ data: any }} result - Result object containing data. + * @property {boolean} aborted - Indicates if the request was aborted. + * @property {string[]} data - Array of data entries. + */ + +/** + * Retuns default state + * @returns {{ + * dataChart: { label: string, data: (string | null)[], backgroundColor: string, borderColor: string, borderWidth: number, }[] | null + * loading: boolean + * locals: string[] | null + * years: string[] | null + * }} + */ +const getDefaultState = () => { + return { + dataChart: null, + loading: false, + locals: null, + years: null, + } +} + +export const useChartStore = defineStore('chart', { + state: () => getDefaultState(), + actions: { + async setChartData() { + this.loading = true + const contentStore = useContentStore() + const response = /** @type{ApiResponseChart} */ ( + await contentStore.requestData({ + detail: true, + stateNameAsCode: false, + stateTotal: true, + }) + ) + + if (!response || response.aborted || !response?.result?.data) { + this.resetState() + return {} + } + + const dataArray = response.result.data + const data = + /** @type{Record>>} */ ({}) + const years = [] + const locals = [] + + // Loop through the dataArray starting from the second element to not get header + let localNames = /** @type string[] */ ([]) + let counter = 0 + for (let i = 1; i < dataArray.length; i++) { + let [year, local, value, population, doses, sickImmunizer] = + dataArray[i] + if (!isNaN(local)) { + local = response.localNames.find( + (/** @type{string} */ name) => name[0] == local + ) + } + if (!localNames.includes(local + sickImmunizer)) { + counter++ + localNames.push(local + sickImmunizer) + } + + if (!data[sickImmunizer]) { + data[sickImmunizer] = {} + } + if (!data[sickImmunizer][year]) { + data[sickImmunizer][year] = {} + } + if (value.at(-1) === '%') { + data[sickImmunizer][year][local] = value.substring( + 0, + value.length - 1 + ) + } else { + data[sickImmunizer][year][local] = value + } + years.push(year) + locals.push(local) + } + + // Extract unique years and locals + this.years = Array.from(new Set(years)).sort() + // TODO: If not necessary as state remove from state + this.locals = Array.from(new Set(locals)) + + // Formating data to chartResult + const chartResult = + /** @type{Record} */ ({}) + for (let local of this.locals) { + for (let [key, val] of Object.entries(data)) { + const legend = `${key} ${local}` + for (let year of this.years) { + if (!chartResult[legend]) { + chartResult[legend] = [] + } + if (val[year] && val[year][local] !== null) { + chartResult[legend].push(val[year][local]) + } else { + chartResult[legend].push(null) + } + } + } + } + + /** + * Generates a random integer between min and max (inclusive). + * + * @param {number} min - The minimum value of the range. + * @param {number} max - The maximum value of the range. + * @returns {number} A random integer within the specified range. + */ + function getRandomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min + } + + const getRandomColor = () => { + // Generate random RGB values, ensuring they are not all 255 (to avoid white) + let r, g, b + do { + r = getRandomInt(0, 255) + g = getRandomInt(0, 255) + b = getRandomInt(0, 255) + } while (r === 255 && g === 255 && b === 255) + + // Return the color in RGB format + return `rgb(${r}, ${g}, ${b})` + } + + const generateUniqueColors = + /** @type{(numColors: number) => string[]} */ (numColors) => { + const colors = new Set() + + while (colors.size < numColors) { + const color = getRandomColor() + colors.add(color) + } + + return Array.from(colors) + } + + const chartResultEntries = Object.entries(chartResult) + + const colorsBase = [ + '#e96f5f', // Base color + '#5f9fe9', // Blue + '#558e5a', // Darker Green + '#e9c35f', // Yellow + '#915fe9', // Purple + '#3ca0a0', // Cyan + '#ff007f', // Shocking Pink + '#666666', // Gray + '#e9a35f', // Orange + ] + + const colors = + chartResultEntries.length > 9 + ? [ + ...colorsBase, + ...generateUniqueColors( + chartResultEntries.length - 9 + ), + ] + : colorsBase + + const dataChart = [] + let i = 0 + + for (let [key, value] of chartResultEntries) { + if (i > 30) { + // TODO: Update to call storeMessage + // store.commit('message/INFO', "Essa filtragem excedeu o máximo de 30 linhas, apenas 30 linhas serão exibidas") + break + } + const color = colors[i % colors.length] + dataChart.push({ + label: key, + data: value, + backgroundColor: color, + borderColor: color, + borderWidth: 2, + }) + i++ + } + + this.dataChart = dataChart + this.loading = false + }, + resetState() { + this.$reset() + }, + }, +}) diff --git a/src/stores/content.js b/src/stores/content.js new file mode 100644 index 0000000..fbd1754 --- /dev/null +++ b/src/stores/content.js @@ -0,0 +1,916 @@ +import { defineStore } from 'pinia' +import { DataFetcher } from '@/data-fetcher' +import { useMessageStore } from '@/stores' +import { formatToApi } from '@/common' +import { getRouter } from '@/router' +import { + disableOptionsByTypeOrDose, + disableOptionsByGranularityOrType, + disableOptionsByTab, + disableOptionsByDoseOrSick, +} from '@/utils' + +/** @type {AbortController} */ +let currentController + +/** @type {AbortController} */ +let currentControllerMap + +/** + * @typedef {object} ContentStateForm + * @property {any | null} sickImmunizer - The selected item or its ID. + * @property {any[]} sicks - A list of options. + * @property {any[]} immunizers - A list of options. + * @property {any | null} type - The selected type. + * @property {any[]} types - A list of options. + * @property {string[] | string} local - Selected locations (multi-select). + * @property {any[]} locals - A list of options. + * @property {any | null} dose - The selected dose. + * @property {any[]} doses - A list of options. + * @property {any | null} period - The selected period. + * @property {any | null} years - Selected years. + * @property {string | null} periodStart - Start date of the period. + * @property {string | null} periodEnd - End date of the period. + * @property {any | null} granularity - Selected granularity. + * @property {any[]} granularities - A list of options. + * @property {any | null} city - The selected city. + * @property {any[]} cities - A list of options. + */ + +/** + * @typedef {object} ContentState + * @property {string} apiUrl - URL base da API + * @property {string} tab - Aba ativa (ex: 'map', 'chart') + * @property {string} tabBy - Agrupamento da aba (ex: 'sicks', 'immunizers') + * @property {string} legend - Texto da legenda + * @property {ContentStateForm} form - Objeto com todos os filtros do formulário + * @property {boolean} yearSlideAnimation - Controla a animação do slider de ano + * @property {any | null} autoFilters - Configurações de filtros automáticos + * @property {any | null} aboutVaccines - Informações "sobre vacinas" + * @property {any | null} mandatoryVaccineYears - Anos de vacina obrigatória + * @property {{ [key: string]: string } | null} lastUpdateDate - Data da última atualização + * @property {{ [key: string]: { [key: string]: string } } | null} titles - Textos de título para seções + * @property {string | null} csvAllDataLink - Link para download de CSV completo + * @property {any | null} doseBlocks - Configuração de blocos de dose + * @property {any | null} granularityBlocks - Configuração de blocos de granularidade + * @property {boolean} disableMap - Desabilita o mapa + * @property {boolean} disableChart - Desabilita os gráficos + * @property {boolean} loading - Estado de loading principal + * @property {number} maxCsvExportRows - Limite de linhas para exportação CSV + * @property {boolean} csvRowsExceeded - Indica se o limite de CSV foi excedido + * @property {any[]} acronyms - Lista de acrônimos + * @property {{ title: string, slug: string } | null} extraFilterButton - Botão extra de interação com modal + */ + +/** + * Return initial default store state + * @returns {ContentState} The object state initial + */ +const getDefaultState = () => { + return { + apiUrl: '', + tab: '', + tabBy: '', + legend: 'Fonte: Programa Nacional de Imunização (PNI), disponibilizadas no TabNet-DATASUS', + form: { + sickImmunizer: null, + sicks: [], + immunizers: [], + type: null, + types: [], + local: [], + locals: [], + dose: null, + doses: [], + period: null, + years: null, + periodStart: null, + periodEnd: null, + granularity: null, + granularities: [], + city: null, + cities: [], + }, + yearSlideAnimation: false, + autoFilters: null, + aboutVaccines: null, + mandatoryVaccineYears: null, + lastUpdateDate: null, + titles: null, + csvAllDataLink: null, + doseBlocks: null, + granularityBlocks: null, + disableMap: false, + disableChart: false, + loading: true, + maxCsvExportRows: 10000, + csvRowsExceeded: false, + acronyms: [], + extraFilterButton: null, + } +} + +export const useContentStore = defineStore('content', { + state: () => getDefaultState(), + actions: { + /** + * Initializes the map fetcher. + * @param { { map: string | string[] } } map - The map identifier (slug) to fetch. + */ + async requestMap({ map }) { + /** @type {DataFetcher} */ + const api = new DataFetcher(this.apiUrl) + + if (currentControllerMap) { + currentControllerMap.abort() + } + + currentControllerMap = new AbortController() + + const signal = currentControllerMap.signal + const result = await api.request(`map/${map}`, signal) + return result + }, + /** + * Request data, process formats and maps states to code if necessary + * @async + * @param {Object} [options={}] - Options for the request. + * @param {boolean} [options.detail=false] - Whether to fetch detailed data. + * @param {boolean} [options.stateNameAsCode=true] - Whether to convert state names to their codes (UF). + * @param {boolean} [options.stateTotal=false] - Whether to include state totals. + * @param {number|null} [options.page=null] - Page number for pagination. + * @param {Object} [options.sorter] - Sorting object. + * @param {string} options.sorter.columnKey - Column to be sorted. + * @param {string} options.sorter.order - Direction of sorting ('ascend' or 'descend'). + * @param {boolean} [options.csv=false] - Whether the request is for CSV export. + * @returns {Promise<{ result: Object, localNames?: any, error?: any, aborted?: boolean } | void>} + * Returns an object containing data and metadata, or void if the form is invalid. + */ + async requestData({ + detail = false, + stateNameAsCode = true, + stateTotal = false, + page = null, + sorter = undefined, + csv = false, + } = {}) { + this.loading = true + if (currentController) { + currentController.abort() + } + + const api = new DataFetcher(this.apiUrl) + const form = this.form + + currentController = new AbortController() + const signal = currentController.signal + + // If the form field 'sickImmunizer' is an array and empty, return without making a request + if ( + form.sickImmunizer && + Array.isArray(form.sickImmunizer) && + !form.sickImmunizer.length + ) { + this.loading = false + return + } + // Ensure all required form fields are populated + if ( + !form.type || + !form.granularity || + !form.sickImmunizer || + !form.dose || + (!form.periodStart && !form.periodEnd) || + (!form.local.length && form.granularity !== 'Nacional') + ) { + this.loading = false + return + } + + // TODO: Add encodeURI to another fields + const sI = Array.isArray(form.sickImmunizer) + ? form.sickImmunizer.join('|') + : form.sickImmunizer + const loc = Array.isArray(form.local) + ? form.local.join('|') + : form.local + let request = + '?tab=' + + this.tab + + '&tabBy=' + + this.tabBy + + '&type=' + + form.type + + '&granularity=' + + form.granularity + + '&sickImmunizer=' + + encodeURIComponent(sI) + + '&local=' + + loc + + '&dose=' + + form.dose + + request += form.periodStart + ? '&periodStart=' + form.periodStart + : '' + request += form.periodEnd ? '&periodEnd=' + form.periodEnd : '' + request += page ? '&page=' + page : '' + request += sorter + ? '&sCol=' + sorter.columnKey + '&sOrder=' + sorter.order + : '' + + if (detail) { + request += '&detail=true' + } + if (stateTotal) { + request += '&stateTotal=true' + } + + const granularity = form.granularity + + const states = form.local + + let isStateData + if (granularity === 'Região de saúde' && states.length > 1) { + isStateData = 'regNames' + } else if (granularity === 'Macrorregião de saúde') { + isStateData = 'macregNames' + } else if (granularity === 'Região de saúde') { + isStateData = 'regNames' + } else if (granularity === 'Estados') { + isStateData = 'statesNames' + } else if (granularity === 'Nacional') { + isStateData = 'countryName' + } else { + isStateData = 'citiesNames' + } + + const [result, localNames] = await Promise.all([ + api.request((csv ? `export-csv/` : `data/`) + request, signal), + api.request(isStateData), + ]) + + if (result.aborted) { + this.loading = false + return { result, localNames: [] } + } + + const messageStore = useMessageStore() + if (result.error) { + messageStore.message( + 'error', + 'Não foi possível carregar os dados. Tente novamente mais tarde.' + ) + this.loading = false + return { result: {}, localNames: [], error: result.error } + } else if (!result || (result.data && result.data.length <= 1)) { + this.titles = null + messageStore.message( + 'warning', + 'Não há dados disponíveis para os parâmetros selecionados.' + ) + this.loading = false + return { result: {}, localNames: [] } + } else if (result.metadata) { + this.titles = result.metadata.titles + this.csvRowsExceeded = result.metadata.csv_rows_exceeded + this.maxCsvExportRows = result.metadata.max_csv_export_rows + } + + if (form.type !== 'Doses aplicadas') { + /** + * Processes the data rows (ignoring the header): + * Converts the value of the third column (index 2) to a string percentage with two decimal places. + * Ex: 0.5 -> "0.50%" + */ + result.data.slice(1).forEach( + /** * @param {string[]} val - Array representando a linha da tabela */ + (val) => (val[2] = Number(val[2]).toFixed(2) + '%') + ) + } else if (form.type === 'Doses aplicadas') { + result.data.forEach( + /** + * @param {string[]} val - Array representing lines of table + * @param {number} index - Current iteration number + */ + (val, index) => { + let number = Number(val[2]) + val[2] = + index > 0 ? number.toLocaleString('pt-BR') : val[2] + } + ) + } + + // Update data to display state names as code + if (result && isStateData === 'statesNames' && stateNameAsCode) { + const newResult = [] + const data = result.data + for (let i = 1; i < data.length; i++) { + const currentData = data[i] + const code = localNames.find( + /** * @param {number[]} val - Array representando a linha da tabela */ + (val) => val[1] === currentData[1] + )[0] + currentData[1] = code + newResult.push(currentData) + } + // Add header + newResult.unshift(data[0]) + result.data = newResult + } + + this.loading = false + return { result, localNames } + }, + /** + * Initializes the data fetcher. + * @param {string} endpoint - The path (slug) to fetch. + */ + async requestPage(endpoint) { + const api = new DataFetcher(this.apiUrl) + const result = await api.requestSettingApiEndPoint( + endpoint, + '/wp-json/wp/v2/pages?' + ) + if (endpoint === 'slug=sobre-vacinas-vacinabr') { + this.aboutVaccines = result + } + }, + /** + * Initializes the data fetcher. + * @param {string} endpoint - The path (slug) to fetch. + */ + async requestJson(endpoint) { + const messageStore = useMessageStore() + const api = new DataFetcher(this.apiUrl) + try { + const result = await api.request(endpoint) + + if (endpoint === 'dose-blocks') { + this.doseBlocks = result + } else if (endpoint === 'granularity-blocks') { + this.granularityBlocks = result + } else if (endpoint === 'link-csv') { + this.csvAllDataLink = result.url + } else if (endpoint === 'mandatory-vaccinations-years') { + this.mandatoryVaccineYears = result + } else if (endpoint === 'lastupdatedate') { + this.lastUpdateDate = result + } else if (endpoint === 'auto-filters') { + this.autoFilters = result + } else if (endpoint === 'acronyms') { + /** @type {Object[]} Final array of formatted objects */ + const finalResult = [] + + /** @type {string[]} The first row contains the headers */ + const acronymsHeader = result[0] + + result.forEach( + /** + * Iterates through matrix rows (skipping header). + * @param {any[]} row - Array representing the table row + * @param {number} i - Current row index + */ + (row, i) => { + // Skip header row (index 0) + if (i < 1) { + return + } + + /** @type {Object.} Object being built */ + const resultRow = {} + + row.forEach( + /** + * Maps column value to the corresponding header key. + * @param {any} col - Cell value + * @param {number} j - Column index + */ + (col, j) => { + resultRow[acronymsHeader[j]] = col + } + ) + + finalResult.push(resultRow) + } + ) + this.acronyms = finalResult + } + } catch (e) { + messageStore.message( + 'error', + `Não foi possível carregar os dados de '/${endpoint}'` + ) + } + }, + /** + * Initializes the data fetcher. + * @param { { map: string } } object - The map identifier (slug) to fetch. + */ + async initial({ map }) { + this.requestMap({ map }) + }, + /** + * Remove query from router. + * @param { string } key - A query to be removed from router. + */ + removeQueryFromRouter(key) { + const router = getRouter() + const messageStore = useMessageStore() + + const searchString = window.location.search + const params = new URLSearchParams(searchString) + const routeArgs = Object.fromEntries(params) + + const URLquery = routeArgs + delete URLquery[key] + + messageStore.message( + 'warning', + 'URL contém valor inválido para filtragem' + ) + + router?.replace({ query: URLquery }) + }, + /** + * Define app form state from URL. + */ + setStateFromUrl() { + const searchString = window.location.search + const params = new URLSearchParams(searchString) + const routeArgs = Object.fromEntries(params) + + const formState = this.form + + /** @type {ContentStateForm} */ + const routerResult = {} + /** @type {{ [key: string]: string[] | string | number }} */ + const routerResultTabs = {} + + if (!Object.keys(routeArgs).length) { + this.setTabField('map') + this.setTabByField('sicks') + return + } + + const routeArgsAsEntries = Object.entries(routeArgs) + + const isTabDefinedInRouterArgs = !routeArgsAsEntries.find( + (item) => item[0] === 'tab' + ) + + const includeTabs = ['chart', 'table'] + const isTabToShowSickAsArray = + !isTabDefinedInRouterArgs || includeTabs.includes(this.tab) + + for (const [key, val] of routeArgsAsEntries) { + if (!val) { + continue + } + const value = String(val) + if (key === 'sickImmunizer') { + if (isTabToShowSickAsArray) { + const values = value.split(',') + const sicks = formState['sicks'].map((el) => el.value) + const immunizers = formState['immunizers'].map( + (el) => el.value + ) + if ( + values.every((val) => sicks.includes(val)) || + values.every((val) => immunizers.includes(val)) + ) { + routerResult[key] = values + } else { + this.removeQueryFromRouter(key) + } + } else if ( + formState['sicks'].some((el) => el.value === value) || + formState['immunizers'].some((el) => el.value === value) + ) { + routerResult[key] = value + } else { + this.removeQueryFromRouter(key) + } + } else if (key === 'city') { + // TODO: define if cities will be in URL state + // const values = value.split(",") + // const cities = formState["cities"].map(el => el.value) + // if (values.every(val => cities.includes(val))) { + // routerResult[key] = values; + // } else { + // removeQueryFromRouter(key); + // } + } else if (key === 'local') { + const values = value.split(',') + const locals = formState['locals'].map((el) => el.value) + if (values.every((val) => locals.includes(val))) { + routerResult.local = values + } else { + this.removeQueryFromRouter(key) + } + } else if (key === 'granularity') { + if ( + formState['granularities'].some( + (el) => el.value === value + ) + ) { + routerResult[key] = value + } else { + this.removeQueryFromRouter(key) + } + } else if (key === 'dose') { + if (formState['doses'].some((el) => el.value === value)) { + routerResult[key] = value + } else { + this.removeQueryFromRouter(key) + } + } else if (key === 'type') { + if (formState['types'].some((el) => el.value === value)) { + routerResult[key] = value + } else { + this.removeQueryFromRouter(key) + } + } else if (key === 'tab') { + if (['map', 'chart', 'table'].some((el) => el === value)) { + routerResultTabs[key] = value + } else { + this.removeQueryFromRouter(key) + } + } else if (key === 'tabBy') { + if (['immunizers', 'sicks'].some((el) => el === value)) { + routerResultTabs[key] = value + } else { + this.removeQueryFromRouter(key) + } + } else if (key === 'periodStart' || key === 'periodEnd') { + const resultValue = Number(value) + if ( + formState['years'].some( + /** @param {{ value: number }} el - Objeto com campo value representando ano */ + (el) => el.value === resultValue + ) + ) { + routerResult[key] = String(resultValue) + } else { + this.removeQueryFromRouter(key) + } + } else if (key === 'period') { + routerResult[key] = Number(value) + } else if (value.includes(',')) { + //@ts-ignore + routerResult[key] = value.split(',') + } else { + //@ts-ignore + routerResult[key] = value ?? null + } + } + + this.setTabField( + routerResultTabs?.tab ? String(routerResultTabs.tab) : 'map' + ) + this.setTabByField( + routerResultTabs?.tabBy + ? String(routerResultTabs.tabBy) + : 'sicks' + ) + + for (let [key, value] of Object.entries(routerResult)) { + this.setFormField(key, value) + } + }, + setUrlFromState() { + const router = getRouter() + const searchString = window.location.search + const params = new URLSearchParams(searchString) + const routeArgs = Object.fromEntries(params) + + let stateResult = formatToApi({ + form: { ...this.form }, + tab: this.tab !== 'map' ? this.tab : undefined, + tabBy: this.tabBy !== 'sicks' ? this.tabBy : undefined, + }) + + if ( + Array.isArray(stateResult.sickImmunizer) && + stateResult.sickImmunizer.length + ) { + stateResult.sickImmunizer = [ + ...stateResult?.sickImmunizer, + ].join(',') + } + if (Array.isArray(stateResult.local) && stateResult.local.length) { + stateResult.local = [...stateResult?.local].join(',') + } + + // TODO: define if cities will be in URL state + + // if (Array.isArray(stateResult.city) && stateResult.city.length) { + // stateResult.city = [...stateResult?.city].join(","); + // } + + delete stateResult.city + + if (JSON.stringify(routeArgs) === JSON.stringify(stateResult)) { + return + } + + router?.replace({ query: stateResult }) + }, + async updateFormSelect() { + const api = new DataFetcher(this.apiUrl) + /** @type {any} payload */ + const payload = {} + const options = await api.request('options') + if (!options) { + return + } + // TODO: make an error handling in case of api offline or instead of value we have an error + for (let [key, value] of Object.entries(options)) { + if (key === 'cities') { + payload[key] = value.map( + ( + /** @type {{ uf: String, nome: String, codigo6: string }} */ item + ) => { + return { + ...item, + label: `${item.uf} - ${item.nome}`, + value: item.codigo6, + } + } + ) + } else { + value.sort() + payload[key] = value.map((/** @type {String} */ item) => { + return { label: item, value: item } + }) + } + } + for (let [key, value] of Object.entries(payload)) { + //@ts-ignore + this.setFormField(key, value) + } + }, + clear() { + this.disableMap = false + this.disableChart = false + const defaultState = getDefaultState() + Object.keys(defaultState.form).forEach((key) => { + // Reset only default select fields, in this case options are not comming from API + if (!key.endsWith('s')) { + //@ts-ignore + this.setFormField(key, defaultState.form[key]) + } + }) + this.tab = 'map' + this.tabBy = 'sicks' + disableOptionsByTypeOrDose(this) + disableOptionsByGranularityOrType(this) + disableOptionsByDoseOrSick(this) + disableOptionsByTab(this) + }, + /** + * Atualiza um campo do formulário e executa as regras de negócio associadas + * @param {string} key - O nome do campo (ex: 'type', 'dose') + * @param {any} value - O novo valor + */ + setFormField(key, value) { + if (key === 'tab') { + this.setTabField(value) + return + } else if (key === 'tabBy') { + this.setTabByField(value) + return + } + if (key === 'periodStart') { + if (!value && this.form.periodEnd) { + this.form.period = this.form.periodEnd + } else { + this.form.period = value + } + } else if (key === 'periodEnd' && !this.form.periodStart) { + // If update and not periodStart, set period as periodEnd value + this.form.period = value + } else if (key === 'sickImmunizer' || key === 'dose') { + disableOptionsByDoseOrSick(this, { [key]: value }) + disableOptionsByTypeOrDose(this, key, value) + // After sickImmunizer update dose select update with type and granularity + const type = this.form.type + if (type) { + disableOptionsByTypeOrDose(this, 'type', type) + disableOptionsByGranularityOrType(this, { type: type }) + } + } else if (key === 'granularity') { + disableOptionsByGranularityOrType(this, { [key]: value }) + } else if (key === 'type') { + disableOptionsByTypeOrDose(this, key, value) + disableOptionsByGranularityOrType(this, { [key]: value }) + } + + // @ts-ignore + this.form[key] = value + + if ( + this.form.sickImmunizer && + this.form.type && + this.form.local.length && + this.form.periodStart && + this.form.periodEnd && + this.form.granularity && + // Avoid unecessary updates and enable use empty dose field + !Object.keys({ key, value }).includes('period') && + !Object.keys({ key, value }).includes('dose') && + !this.form.dose + ) { + const activeDoses = this.form.doses.filter( + (dose) => !dose.disabled + ) + if (activeDoses.length) { + const newDose = activeDoses[activeDoses.length - 1].value + disableOptionsByDoseOrSick(this, { dose: newDose }) + disableOptionsByTypeOrDose(this, 'dose', newDose) + this.form.dose = newDose + } + } + this.checkGramWithState() + }, + checkGramWithState() { + if ( + this.form.granularity === 'Municípios' && + this.form.local.length > 1 + ) { + if (this.tab === 'map') { + this.setTabField('table') + } + this.disableMap = true + } else if ( + this.form.granularity === 'Municípios' || + this.form.type === 'Meta atingida' + ) { + this.disableMap = false + } else { + this.disableMap = false + this.disableChart = false + } + }, + /** + * @param {string} value - New tab value selected + */ + setTabField(value) { + const messageStore = useMessageStore() + this.tab = value + + if (['table', 'chart'].includes(value)) { + if (!this.form.sickImmunizer) { + this.form.sickImmunizer = [] + } else if (!Array.isArray(this.form.sickImmunizer)) { + this.form.sickImmunizer = [this.form.sickImmunizer] + messageStore.message( + 'info', + 'Seletores atualizados para tipo de exibição selecionada' + ) + } + } else if ( + this.form.sickImmunizer && + Array.isArray(this.form.sickImmunizer) && + this.form.sickImmunizer.length > 0 + ) { + this.form.sickImmunizer = this.form.sickImmunizer[0] + disableOptionsByDoseOrSick(this, { + ['sickImmunizer']: this.form.sickImmunizer, + }) + messageStore.message( + 'info', + 'Seletores atualizados para tipo de exibição selecionada' + ) + } else { + this.form.sickImmunizer = null + } + + this.checkGramWithState() + }, + /** + * @param {string} value - New value tab selected + */ + setTabByField(value) { + disableOptionsByGranularityOrType(this) + disableOptionsByTab(this, { tabBy: value }) + this.tabBy = value + + this.form.sickImmunizer = Array.isArray(this.form.sickImmunizer) + ? [] + : null + this.form.dose = null + this.form.granularity = null + this.form.type = null + + disableOptionsByTypeOrDose(this) + disableOptionsByDoseOrSick(this) + }, + }, + getters: { + disableLocalSelect: (state) => { + const granularity = state.form.granularity + + if (granularity === 'Nacional') { + state.form.local = [] + return true + } + return false + }, + // TODO: Fix title not being setted after chart update data + mainTitle: (state) => { + let title = null + const { sickImmunizer, dose, granularity, local, period, type } = + state.form + if ( + sickImmunizer && + Array.isArray(sickImmunizer) && + !sickImmunizer.length + ) { + return + } + if ( + !dose || + !granularity || + !period || + !sickImmunizer || + !type || + (!local.length && granularity !== 'Nacional') + ) { + return + } + if (state.titles) { + if (state.tab === 'map' && state.titles.map) { + title = state.titles.map?.title + ' em ' + period + } else { + title = state.titles.table.title + } + } + return title + }, + subTitle: (state) => { + let subtitle = null + const { sickImmunizer, dose, granularity, local, period, type } = + state.form + if ( + sickImmunizer && + Array.isArray(sickImmunizer) && + !sickImmunizer.length + ) { + return + } + if ( + !granularity || + !period || + !sickImmunizer || + !type || + (!local.length && granularity !== 'Nacional') || + !dose + ) { + return + } + + if (state.titles) { + subtitle = + state.tab === 'map' + ? state.titles.map?.subtitle + : state.titles.table.subtitle + } + return subtitle + }, + selectsPopulated: (state) => { + const { sickImmunizer, dose, granularity, local, period, type } = + state.form + + const isSickImuAnArray = + sickImmunizer && Array.isArray(sickImmunizer) + const isSickImuFilledArray = + isSickImuAnArray && sickImmunizer.length + const isSickImuFilledField = !isSickImuAnArray && sickImmunizer + return ( + (isSickImuFilledArray || isSickImuFilledField) && + dose && + granularity && + (local.length || + (!local.length && granularity === 'Nacional')) && + period && + type + ) + }, + selectsEmpty: (state) => { + const form = state.form + if ( + // If sickImmunizer selected in map or if sickImmunizer array is empty in chart and tables + (form.sickImmunizer && !Array.isArray(form.sickImmunizer)) || + (form.sickImmunizer && form.sickImmunizer.length) || + form.type || + form.local.length || + form.period || + form.granularity + ) { + return false + } + return !state.loading + }, + }, +}) diff --git a/src/stores/index.js b/src/stores/index.js new file mode 100644 index 0000000..e1c9a28 --- /dev/null +++ b/src/stores/index.js @@ -0,0 +1,6 @@ +export * from '@/stores/chart' +export * from '@/stores/content' +export * from '@/stores/map' +export * from '@/stores/message' +export * from '@/stores/modal' +export * from '@/stores/table' diff --git a/src/stores/map.js b/src/stores/map.js new file mode 100644 index 0000000..70469e4 --- /dev/null +++ b/src/stores/map.js @@ -0,0 +1,268 @@ +import { defineStore } from 'pinia' +import { useContentStore } from '@/stores/content' +import { storeToRefs } from 'pinia' + +import { convertArrayToObject } from '@/utils' + +import { MapChart } from '@/map-chart' + +/** + * @typedef {Object} ApiResponseMap + * @property {string[]} localNames - Array of local names. + * @property {{ data: any }} result - Result object containing data. + * @property {boolean} aborted - Indicates if the request was aborted. + * @property {any[]} data - Array of data entries. + */ + +/** + * Retuns default state + * @returns {{ + * mapElement: null | HTMLElement + * map: any + * datasetCities: any + * datasetStates: any + * loadingMap: boolean + * mapChart: any + * currentLocal: any + * mapData: MapChart | null + * mapTooltip: any + * }} + */ +const getDefaultState = () => { + return { + mapElement: null, + map: null, + datasetCities: null, + datasetStates: null, + loadingMap: false, + mapChart: null, + currentLocal: null, + mapData: null, + mapTooltip: null, + } +} + +export const useMapStore = defineStore('map', { + state: () => getDefaultState(), + actions: { + updatePeriod() { + const contentStore = useContentStore() + const { form } = storeToRefs(contentStore) + const startYear = form.value.periodStart + const endYear = form.value.periodEnd + // If updated select start or end year update period + if (startYear && endYear) { + const cities = this.datasetCities + const states = this.datasetStates + if (cities) { + form.value.period = Number(Object.keys(cities)[0]) + } else if (states) { + form.value.period = Number(Object.keys(states)[0]) + } + } + }, + /** + * @param {string} period + */ + updatePeriodManual(period) { + if (this.datasetStates) { + this.renderMap({ + element: this.mapElement, + map: this.map, + loading: this.loadingMap, + datasetStates: this.datasetStates[period], + }) + } else if (this.datasetCities) { + this.renderMap({ + element: this.mapElement, + map: this.map, + loading: this.loadingMap, + datasetCities: this.datasetCities[period], + }) + } + }, + /** + * Renders or updates a map chart based on the provided arguments. + * + * @param {any} args - The arguments for rendering the map chart. + */ + renderMap(args) { + const contentStore = useContentStore() + const { form, selectsPopulated } = storeToRefs(contentStore) + const type = form.value.type + + if (!this.mapChart) { + this.mapChart = new MapChart({ + ...args, + type, + formPopulated: selectsPopulated.value, + /** + * @param {boolean} opened + * @param {string} name + * @param {string|number} id + */ + tooltipAction: (opened, name, id) => { + this.mapTooltip = { opened, name, id, type } + }, + }) + } else { + /** @type {MapChart} */ this.mapChart.update({ + ...args, + type, + formPopulated: selectsPopulated.value, + }) + } + + if (this.mapChart) { + this.mapData = /** @type {MapChart} */ ( + this.mapChart.datasetValues + ) + } + }, + async setMapData() { + const contentStore = useContentStore() + const { form } = storeToRefs(contentStore) + + const granularity = form.value.granularity + let local = form.value.local + if (granularity === 'Nacional') { + local = ['BR'] + } + if (!local) { + return + } + const period = form.value.period + + this.datasetCities = null + this.datasetStates = null + + this.loadingMap = true + const results = /** @type{ApiResponseMap} */ ( + await contentStore.requestData() + ) + this.loadingMap = false + + if (results && results.aborted) { + this.loadingMap = false + return + } + + try { + let mapSetup = + /** @type{{ element: any, map: any, datasetCities: any, datasetStates: any, cities: null | string[], states: null | string[], statesSelected: any, loading: boolean }} */ ({ + element: this.mapElement, + map: this.map, + datasetStates: null, + datasetCities: null, + cities: null, + loading: this.loadingMap, + }) + + if (local.length === 1) { + this.datasetCities = convertArrayToObject( + results.result.data + ).data + this.datasetStates = null + this.updatePeriod() + mapSetup = { + ...mapSetup, + datasetCities: this.datasetCities + ? this.datasetCities[period] + : null, + cities: results.localNames, + loading: this.loadingMap, + } + } else { + this.datasetCities = null + this.datasetStates = convertArrayToObject( + results.result.data + ).data + this.updatePeriod() + mapSetup = { + ...mapSetup, + datasetStates: this.datasetStates + ? this.datasetStates[period] + : null, + states: results.localNames, + statesSelected: local, + loading: this.loadingMap, + } + } + this.renderMap(mapSetup) + } catch (e) { + this.renderMap({ + element: this.mapElement, + map: this.map, + loading: this.loadingMap, + }) + } + }, + async updateMap() { + const contentStore = useContentStore() + const { form } = storeToRefs(contentStore) + + const granularity = form.value.granularity + const local = form.value.local + + this.loadingMap = true + if (local.length === 1) { + if (local + granularity !== this.currentLocal) { + this.map = await this.queryMap(local) + } + if (this.map.aborted) { + this.loadingMap = false + return + } + this.currentLocal = local + granularity + } else if (local + granularity !== this.currentLocal) { + this.map = await this.queryMap('BR') + if (this.map.aborted) { + this.loadingMap = false + return + } + + this.renderMap({ + element: this.mapElement, + map: this.map, + loading: this.loadingMap, + }) + this.currentLocal = 'BR' + granularity + } + }, + /** + * @param {string[] | string} local - local to query map. + */ + async queryMap(local) { + const contentStore = useContentStore() + const { form } = storeToRefs(contentStore) + + let maplocal + + if ( + form.value.granularity === 'Macrorregião de saúde' && + local.length > 1 + ) { + maplocal = 'macreg/BR' + } else if (form.value.granularity === 'Macrorregião de saúde') { + maplocal = `macreg/${local}` + } else if ( + form.value.granularity === 'Região de saúde' && + local.length > 1 + ) { + maplocal = `reg/BR` + } else if (form.value.granularity === 'Região de saúde') { + maplocal = `reg/${local}` + } else if (form.value.granularity === 'Estados') { + maplocal = 'BR-UF' + } else if (form.value.granularity === 'Nacional') { + maplocal = 'BR' + } else { + maplocal = local + } + + const file = await contentStore.requestMap({ map: maplocal }) + + return file + }, + }, +}) diff --git a/src/stores/message.js b/src/stores/message.js new file mode 100644 index 0000000..8e23ae0 --- /dev/null +++ b/src/stores/message.js @@ -0,0 +1,39 @@ +import { defineStore } from 'pinia' + +/** + * Returns default state for message store + * @returns {{ + * type: 'success' | 'info' | 'warning' | 'error' | null, + * text: string | null, + * duration: number | null + * }} + */ +const getDefaultState = () => { + return { + type: null, + text: null, + duration: null, + } +} + +export const useMessageStore = defineStore('message', { + state: () => getDefaultState(), + actions: { + /** + * @param {'success'|'info'|'warning'|'error'} type - The type of message. + * @param {string} text - The text to display in the alert. + * @param {number} [duration=3000] - The duration for which the message should be displayed, in milliseconds. + */ + message(type, text, duration = 3000) { + this.type = type + this.text = text + this.duration = duration + }, + clear() { + const { type, text, duration } = getDefaultState() + this.type = type + this.text = text + this.duration = duration + }, + }, +}) diff --git a/src/stores/modal.js b/src/stores/modal.js new file mode 100644 index 0000000..dfe3a23 --- /dev/null +++ b/src/stores/modal.js @@ -0,0 +1,42 @@ +import { defineStore } from 'pinia' +import { DataFetcher } from '@/data-fetcher' +import { useContentStore } from '@/stores' + +/** + * @typedef {object} ModalState + * @property {boolean} genericModalLoading - Estado de loading do modal genérico + * @property {boolean} genericModalShow - Controla a exibição do modal genérico + * @property {string | null} extraFilterButton - Configuração de botão de filtro extra + * @property {{[key: string]: any; error?: Error | undefined; aborted?: boolean | undefined;} | null} genericModal - Conteúdo do modal genérico + * @property {string | null} genericModalTitle - Título do modal genérico + * @returns {ModalState} The object state initial + */ +const getDefaultState = () => { + return { + genericModal: null, + genericModalTitle: null, + genericModalLoading: false, + extraFilterButton: null, + genericModalShow: false, + } +} + +export const useModalStore = defineStore('modal', { + state: () => getDefaultState(), + actions: { + /** + * Request page data + * @param {string} slug - Url path + * @return Promise + */ + async requestContent(slug) { + const contentStore = useContentStore() + const api = new DataFetcher(contentStore.apiUrl) + const result = await api.requestSettingApiEndPoint( + slug, + '/wp-json/wp/v2/pages' + ) + this.genericModal = result + }, + }, +}) diff --git a/src/stores/table.js b/src/stores/table.js new file mode 100644 index 0000000..2789ccb --- /dev/null +++ b/src/stores/table.js @@ -0,0 +1,94 @@ +import { defineStore } from 'pinia' +import { useContentStore } from '@/stores' +import { formatToTable } from '@/utils' + +/** + * @typedef {object} ApiResponseTable + * @property {string[]} localNames + * @property {{ data: any, metadata: { pages: { total_pages: string, total_records: string }, type: string } }} result + * @property {any} error + * @property {boolean} aborted + * @property {string[]} data + */ + +/** + * Retuns default state + * @returns {{ + * columns: { title: string, minWidth: string | number, key: string }[] + * loading: boolean + * page: number + * pageCount: number + * pageTotalItems: number + * rows: string[] + * sorter: { columnKey: string, order: string } | undefined + * }} + */ +const getDefaultState = () => { + return { + columns: [], + loading: false, + page: 1, + pageCount: 0, + pageTotalItems: 10, + rows: [], + sorter: undefined, + } +} + +export const useTableStore = defineStore('table', { + state: () => getDefaultState(), + actions: { + async setTableData() { + this.loading = true + const contentStore = useContentStore() + const response = /** @type{ApiResponseTable} */ ( + await contentStore.requestData({ + detail: true, + page: this.page, + sorter: this.sorter, + }) + ) + + if (response?.aborted || !response || !response.result.data) { + this.rows = [] + this.loading = false + return + } + + this.pageCount = Number(response.result.metadata.pages.total_pages) + this.pageTotalItems = Number( + response.result.metadata.pages.total_records + ) + + const tableData = formatToTable( + response.result.data, + response.localNames, + response.result.metadata + ) + this.columns = + /** @type {{ title: string, minWidth: string | number, key: string }[]} */ ( + tableData.header + ) + const dosesQtd = this.columns.findIndex( + (column) => column.title === 'Doses (qtd)' + ) + if (response.result.metadata.type == 'Doses aplicadas') { + this.columns.splice(dosesQtd, 1) + } else { + this.columns[dosesQtd].minWidth = '130px' + } + const columnValue = this.columns.find( + (column) => column.key === 'valor' + ) + if (columnValue) { + columnValue.minWidth = '160px' + columnValue.title = response.result.metadata.type + } + this.rows = /** @type {string[]} */ (tableData.rows) + this.loading = false + }, + resetState() { + this.$reset() + }, + }, +}) diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..7599427 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,696 @@ +/** + * @typedef {Object} SelectOption + * @property {string} label + * @property {string|number} value + * @property {boolean} [disabled] + * @property {string} [disabledText] + * @property {string} [toLowerCase] + */ + +/** + * @typedef {Object} VueState + * @property {Object} form + * @property {SelectOption[]} form.doses + * @property {SelectOption[]} form.types + * @property {SelectOption[]} form.granularities + * @property {string|null} form.dose + * @property {string|null} form.type + * @property {string|string[]|Object[]} [form.sickImmunizer] + * @property {SelectOption[]} [form.sicks] + * @property {SelectOption[]} [form.immunizers] + * @property {string} tabBy + * @property {Array>} doseBlocks + * @property {Array>} granularityBlocks + */ + +/** + * @param {string|number|Date} timestamp + * @returns {number} + */ +export const timestampToYear = (timestamp) => { + const date = new Date(timestamp) + const year = date.getFullYear() + return year +} + +/** + * @param {Object} options + * @param {string[]} options.fields + * @param {Record} options.store + * @param {string} [options.base] + * @returns {Object} + */ +export const mapFields = (options) => { + /** @type {Record} */ + const object = {} + for (let i = 0; i < options.fields.length; i++) { + const field = options.fields[i] + object[field] = { + get() { + // Pinia: Acesso direto (sem .state) + if (options.base) { + return options.store[options.base][field] + } + return options.store[field] + }, + /** @param {any} value */ + set(value) { + // Pinia: Atribuição direta (sem .commit) + if (options.base) { + options.store[options.base][field] = value + } else { + options.store[field] = value + } + }, + } + } + return object +} + +/** + * Extracts and formats the year from a given timestamp. + * + * @param {string|number|Date} timestamp - The timestamp to be formatted. + * @returns {string} The year extracted from the timestamp, formatted as a four-digit string. + */ +export const formatDate = (timestamp) => { + const date = new Date(timestamp) + const year = date.getFullYear().toString().padStart(4, '0') + return year +} + +/** + * @param {string} dateString - The date string to be converted to UTC. + * @returns {number} - The number representing the UTC timestamp. + */ +export const convertDateToUtc = (dateString) => { + // @ts-ignore + const utcDate = Date.UTC(dateString, 1, 1) + return utcDate +} + +/** + * Converts an array of data into a table format. + * + * @param {string[]} data - The array of data to convert. + * @param {string[]} localNames - Array of local names corresponding to the data. + * @param {Object} [metadata] - Metadata object containing additional information. + * @param {string} [metadata.type] - Type of metadata. + * @returns {{header: Array, rows: Array}} An object containing the header and rows for the table. + */ +export const formatToTable = (data, localNames, metadata) => { + /** @type {Array} */ + let header = [] + for (const column of [...data[0], 'código']) { + // Setting width and behaveours of table column + let width = null + /** @type {number|string} */ + let align = 0 + /** @type {number|null} */ + let minWidth = 200 + if (['ano', 'valor', 'população', 'doses', 'código'].includes(column)) { + align = 'right' + width = 120 + minWidth = null + } + // Formating table title + let title = column.charAt(0).toUpperCase() + column.slice(1) + if (title === 'Doenca') { + title = 'Doença' + } else if (title === 'Doses') { + title = 'Doses (qtd)' + } + header.push({ + title, + key: column, + sorter: ['código', 'local'].includes(column) ? false : 'default', + width, + titleAlign: 'left', + align, + minWidth, + }) + } + + const index = localNames[0].indexOf('geom_id') + const indexName = localNames[0].indexOf('name') + const indexUF = localNames[0].indexOf('uf') + const indexAcronym = localNames[0].indexOf('acronym') + /** @type {Array>} */ + const rows = [] + + // Loop api return value + for (let i = 1; i < data.length; i++) { + /** @type {Record} */ + const row = {} + // Setting value as key: value in row object + for (let j = 0; j < data[i].length; j++) { + const key = header[j].key + const value = data[i][j] + if (key === 'local') { + let localResult = + localNames.find((localName) => localName[index] == value) || + localNames.find( + (localName) => localName[indexAcronym] == value + ) + if (!localResult) { + continue + } + let name = localResult[indexName] + let ufAcronymName = localResult[indexUF] + if (ufAcronymName) { + name += ' - ' + ufAcronymName + } + row['código'] = value + row[header[j].key] = name + continue + } else if (['população', 'doses'].includes(key)) { + // @ts-ignore + row[header[j].key] = value.toLocaleString('pt-BR') + continue + } else if ( + metadata && + metadata.type == 'Meta atingida' && + key == 'valor' + ) { + row[header[j].key] = parseInt(value) === 1 ? 'Sim' : 'Não' + continue + } + row[header[j].key] = value + } + // Pushing result row + rows.push(row) + } + + header.splice(1, 0, header.splice(6, 1)[0]) + + return { header, rows } +} + +/** + * Converts an array of data into an object, where each year is a key and its value is another object containing local data. + * + * @param {Array>} inputArray - The input array to convert. The first element should be the header row. + * @returns {{header: Array, data: Object}} An object containing the header and data extracted from the input array. + */ +export const convertArrayToObject = (inputArray) => { + /** @type {Record} */ + const data = {} + + // Loop through the input array starting from the second element + for (let i = 1; i < inputArray.length; i++) { + const [year, local, value, population, doses] = inputArray[i] + if (!data[year]) { + data[year] = {} + } + + data[year][local] = { value, population, doses } + } + + return { header: inputArray[0], data } +} + +/** + * Creates a debounced function. + * + * @returns {function(Function, number=): void} - A debounced version of the input function. + */ +export const createDebounce = () => { + /** @type {number | undefined} */ + let timer + return (fn, wait = 300) => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + if (typeof fn === 'function') { + fn() + } + }, wait) + } +} + +/** + * @param {string} input + * @returns {string} + */ +export const convertToLowerCaseExceptInParentheses = (input) => { + let result = '' + let insideParentheses = false + for (let i = 0; i < input.length; i++) { + const char = input[i] + if (char === '(') { + insideParentheses = true + } else if (char === ')') { + insideParentheses = false + } + if (insideParentheses) { + result += char + } else { + result += char.toLowerCase() + } + } + return result +} + +/** + * @param {VueState['form']} form + * @returns {[string|string[]|null, boolean]} + */ +export const sickImmunizerAsText = (form) => { + /** @type {string|string[]|null} */ + let sickImmunizer = null + let multipleSickImmunizer = false + if (Array.isArray(form.sickImmunizer) && form.sickImmunizer.length > 1) { + if (form.sickImmunizer.length > 2) { + sickImmunizer = + convertToLowerCaseExceptInParentheses( + /** @type {string[]} */ (form.sickImmunizer) + .slice(0, -1) + .join(', ') + ) + + ' e ' + + convertToLowerCaseExceptInParentheses( + /** @type {string} */ ( + form.sickImmunizer[form.sickImmunizer.length - 1] + ) + ) + } else { + sickImmunizer = convertToLowerCaseExceptInParentheses( + /** @type {string[]} */ (form.sickImmunizer).join(' e ') + ) + } + multipleSickImmunizer = true + } else if (form.sickImmunizer && !Array.isArray(form.sickImmunizer)) { + sickImmunizer = convertToLowerCaseExceptInParentheses( + /** @type {string} */ (form.sickImmunizer) + ) + } else if (Array.isArray(form.sickImmunizer) && form.sickImmunizer.length) { + // @ts-ignore + sickImmunizer = form.sickImmunizer.map((x) => + // @ts-ignore + convertToLowerCaseExceptInParentheses(x.toLowerCase) + ) + } + + return [sickImmunizer, multipleSickImmunizer] +} + +/** + * @param {Array} array + * @param {Object} [object={ disabled: false }] + */ +const resetOptions = (array, object = { disabled: false }) => { + for (let i = 0; i < array.length; i++) { + array[i] = { + ...array[i], + ...object, + } + } +} + +/* + * TODO: Make dose selector disable field in selector type of + * data instead of change it's data + */ + +/* + * TODO: Enhance situation where select type of data update Dose + * field as 3ª dose + */ + +/** + * @param {VueState} state + * @param {string} [formKey] + * @param {any} [formValue] + */ +export const disableOptionsByTypeOrDose = (state, formKey, formValue) => { + const disabledTextAbandono = + 'Essa informação não está disponível para 1ª dose' + const disabledText1Dose = + 'Essa informação não está disponível para Abandono' + if (formKey == 'type' && formValue == 'Abandono') { + const doses = state.form.doses + /** @type {number} */ + // @ts-ignore + const index = doses.indexOf(doses.find((el) => el.label === '1ª dose')) + doses[index] = { + ...doses[index], + disabled: true, + disabledText: disabledTextAbandono, + } + if (state.form.dose == doses[index].label) { + state.form.dose = null + } + } else if (formKey == 'type' && formValue != 'Abandono') { + const doses = state.form.doses + /** @type {number} */ + // @ts-ignore + const index = doses.indexOf(doses.find((el) => el.label === '1ª dose')) + doses[index] = { + ...doses[index], + disabled: false, + disabledText: disabledText1Dose, + } + } else if (formKey == 'dose' && formValue == '1ª dose') { + const types = state.form.types + /** @type {number} */ + // @ts-ignore + const index = types.indexOf(types.find((el) => el.label == 'Abandono')) + types[index] = { + ...types[index], + disabled: true, + disabledText: disabledTextAbandono, + } + if (state.form.type == types[index].label) { + state.form.type = null + } + } else if (formKey == 'dose' && formValue != '1ª dose') { + const types = state.form.types + /** @type {number} */ + // @ts-ignore + const index = types.indexOf(types.find((el) => el.label === 'Abandono')) + types[index] = { + ...types[index], + disabled: false, + disabledText: disabledText1Dose, + } + } else if (!formKey) { + // CLEAR_STATE + const doses = state.form.doses + const types = state.form.types + // @ts-ignore + doses[ + // @ts-ignore + doses.indexOf(doses.find((el) => el.label === '1ª dose')) + ].disabled = false + types[ + // @ts-ignore + types.indexOf(types.find((el) => el.label === 'Abandono')) + ].disabled = false + } +} + +/** + * @param {VueState} state + * @param {Object} [payload] + * @param {string} [payload.tabBy] + */ +export const disableOptionsByTab = (state, payload) => { + if (payload && payload.tabBy == 'immunizers') { + const types = state.form.types + const index = types.indexOf( + // @ts-ignore + types.find((el) => el.label == 'Homogeneidade entre vacinas') + ) + types[index] = { ...types[index], disabled: false } + } else { + const types = state.form.types + const index = types.indexOf( + // @ts-ignore + types.find((el) => el.label == 'Homogeneidade entre vacinas') + ) + types[index] = { + ...types[index], + disabled: true, + disabledText: + 'Essa informação está disponível apenas no recorte por vacina', + } + if (state.form.type == types[index].label) { + state.form.type = null + } + } +} + +/** + * @param {string} value + * @returns {string} + */ +const blockHeaderName = (value) => { + const firstLetter = value[0] + const lastLetter = value[value.length - 1] + return (lastLetter === 'o' ? 'r' : '') + firstLetter +} + +/** + * @param {VueState} state + * @param {Object} [payload] + */ +export const disableOptionsByDoseOrSick = (state, payload) => { + const sicksImmunizers = + state.tabBy === 'sicks' ? state.form['sicks'] : state.form['immunizers'] + // @ts-ignore + const doses = state.form.doses + + if (!payload) { + // CLEAR_STATE + // @ts-ignore + resetOptions(sicksImmunizers) + resetOptions(doses) + return + } + + const blockedListHeader = [...state.doseBlocks[0]] + const blockedListRows = [...state.doseBlocks] + + // Removing header row from blockedListRows + blockedListRows.splice(0, 1) + + const selected = Object.entries(payload)[0] + const selectedValue = selected[1] + + const listIndexType = blockedListHeader.findIndex((el) => el === 'tipo') + const type = state.tabBy === 'immunizers' ? 'vacina' : 'doenca' + const listIndexSickImmuno = blockedListHeader.findIndex( + (el) => el === 'doenca_imuno' + ) + if (selected[0] === 'dose') { + if (!selectedValue) { + // CLEAR_STATE + // @ts-ignore + resetOptions(sicksImmunizers) + return + } + const listIndex = blockedListHeader.findIndex( + (el) => el === blockHeaderName(selectedValue) + ) + + // @ts-ignore + for (let i = 0; i < sicksImmunizers.length; i++) { + const blockedListRow = blockedListRows.find( + (blr) => + // @ts-ignore + blr[listIndexSickImmuno] === sicksImmunizers[i].label && + blr[listIndexType] && + blr[listIndexType] + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') === type + ) + const disabled = + blockedListRow && blockedListRow[listIndex] === false + ? true + : false + + // @ts-ignore + sicksImmunizers[i] = { + // @ts-ignore + ...sicksImmunizers[i], + disabled, + disabledText: 'Não selecionável para essa dose.', + } + } + } else if (selected[0] === 'sickImmunizer') { + if (!selectedValue) { + // CLEAR_STATE + resetOptions(doses) + return + } + let resultToBlock + + if (Array.isArray(selectedValue)) { + resultToBlock = blockedListRows.filter( + (blr) => + selectedValue.includes(blr[listIndexSickImmuno]) && + blr[listIndexType] && + blr[listIndexType] + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') === type + ) + } else { + resultToBlock = blockedListRows.find((blr) => { + return ( + blr[listIndexSickImmuno] === selectedValue && + blr[listIndexType] && + blr[listIndexType] + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') === type + ) + }) + } + + for (let i = 0; i < doses.length; i++) { + let disabled + if (resultToBlock && Array.isArray(selectedValue)) { + for (let result of resultToBlock) { + disabled = false + if ( + result[ + blockedListHeader.findIndex( + (el) => el === blockHeaderName(doses[i].label) + ) + ] === false + ) { + disabled = true + break + } + } + } else if (resultToBlock) { + // Its not a multiple values select + disabled = + resultToBlock[ + blockedListHeader.findIndex( + (el) => el === blockHeaderName(doses[i].label) + ) + ] === true + ? false + : true + } + + doses[i] = { + ...doses[i], + disabled, + disabledText: 'Não selecionável para essa doença/vacina', + } + } + } + + const doseFinded = doses.find((dose) => dose.value === state.form.dose) + if (doseFinded && doseFinded.disabled) { + state.form.dose = null + } +} + +/** + * @param {string} date + * @returns {string} + */ +export const formatDatePtBr = (date) => { + const inputDate = new Date(date + 'T00:00:00') + // @ts-ignore + const options = { year: 'numeric', month: '2-digit', day: '2-digit' } + // @ts-ignore + const formatter = new Intl.DateTimeFormat('pt-BR', options) + return formatter.format(inputDate) +} + +/** + * @param {VueState} state + * @param {Object} [payload] + */ +export const disableOptionsByGranularityOrType = (state, payload) => { + const granularities = state.form.granularities + const types = state.form.types + + if (!payload) { + // CLEAR_STATE + resetOptions(granularities) + resetOptions(types) + return + } + + const selected = Object.entries(payload)[0] + const selectedValue = selected[1] + const blockedListHeader = [...state.granularityBlocks[0]] + const blockedListRows = [...state.granularityBlocks] + + // Removing header row from blockedListRows + blockedListRows.splice(0, 1) + const granularityColumnIndex = blockedListHeader.findIndex( + (el) => el === 'granularidade' + ) + const hv = 'homogeneidade_entre_vacinas' + const hg = 'homogeneidade_geografica' + const hvColumnIndex = blockedListHeader.findIndex((el) => el === hv) + const hgColumnIndex = blockedListHeader.findIndex((el) => el === hg) + + if (selected[0] === 'granularity') { + /** @type {SelectOption} */ + // @ts-ignore + const hvOpt = types.find( + // @ts-ignore + (type) => strToSnakeCaseNormalize(type.value) === hv + ) + /** @type {SelectOption} */ + // @ts-ignore + const hgOpt = types.find( + // @ts-ignore + (type) => strToSnakeCaseNormalize(type.value) === hg + ) + if (!selectedValue) { + if (state.tabBy === 'immunizers') { + hvOpt.disabled = false + } + hgOpt.disabled = false + return + } + const elRow = blockedListRows.find( + (el) => el[granularityColumnIndex] === selectedValue.toLowerCase() + ) + + if (!elRow) { + return + } + + if (state.tabBy === 'immunizers') { + hvOpt.disabled = !elRow[hvColumnIndex] + hvOpt.disabledText = 'Não selecionável para essa granularidade' + } + hgOpt.disabled = !elRow[hgColumnIndex] + hgOpt.disabledText = 'Não selecionável para essa granularidade' + } else if (selected[0] === 'type') { + if (!selectedValue) { + granularities.forEach( + (granularity) => (granularity.disabled = false) + ) + return + } + const listIndex = blockedListHeader.findIndex( + (el) => el === strToSnakeCaseNormalize(selectedValue) + ) + if (listIndex < 1) { + granularities.forEach( + (granularity) => (granularity.disabled = false) + ) + return + } + /** @type {any[]} */ + const resultToBlock = [] + blockedListRows.forEach((el) => { + if (!el[listIndex]) { + resultToBlock.push(el[granularityColumnIndex]) + } + }) + granularities.forEach((granularity) => { + if ( + resultToBlock.includes(String(granularity.value).toLowerCase()) + ) { + granularity.disabled = true + granularity.disabledText = + 'Não selecionável para essa tipo de dado' + return + } + granularity.disabled = false + }) + } +} + +/** + * @param {string} str + * @returns {string} + */ +const strToSnakeCaseNormalize = (str) => + str + .toLowerCase() + .replace(/\s+/g, '_') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') diff --git a/tsconfig.json b/tsconfig.json index 002cdd0..7d69239 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,8 +11,12 @@ "paths": { "@/*": ["./src/*"] }, - "lib": ["ES2020", "DOM"] + "downlevelIteration": true, + "lib": ["ES2024", "DOM", "DOM.Iterable"] }, "exclude": ["node_modules", "dist"], - "include": ["src/**/*"] + "include": [ + "src/**/*", + "images.d.ts" + ] } From 77570c83ea8c79875951bb882d139ae4533b7332 Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Tue, 6 Jan 2026 15:31:09 -0300 Subject: [PATCH 28/31] Add cities selector --- .../main/card-components/sub-select.js | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/components/main/card-components/sub-select.js b/src/components/main/card-components/sub-select.js index 418867b..bfbf475 100644 --- a/src/components/main/card-components/sub-select.js +++ b/src/components/main/card-components/sub-select.js @@ -680,6 +680,47 @@ export default defineComponent({ :consistent-menu-width="false" /> + + + + + + + + From 3e9f20cc58d5b748fa0f4c7b8ca0efa10957581c Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Wed, 7 Jan 2026 14:48:14 -0300 Subject: [PATCH 29/31] Add message alert --- src/components/main/card-components/sub-select.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/main/card-components/sub-select.js b/src/components/main/card-components/sub-select.js index bfbf475..f17fee1 100644 --- a/src/components/main/card-components/sub-select.js +++ b/src/components/main/card-components/sub-select.js @@ -25,6 +25,8 @@ import { storeToRefs } from 'pinia' import { useContentStore } from '@/stores' import { biEraser } from '@/icons' +import { useMessageStore } from '@/stores' + export default defineComponent({ components: { NButton, @@ -43,6 +45,7 @@ export default defineComponent({ }, }, setup(props) { + const messageStore = useMessageStore() /** @type {any[]} */ const allCitiesValues = [] @@ -320,7 +323,7 @@ export default defineComponent({ if (valueLength > maxSelection) { formValue.city = value.slice(0, maxSelection) cityTemp.value = formValue.city - // store.commit("message/INFO", "Valores de seletor de municípios foram atualizado para limites de gráfico"); // Substituir por lógica de notificação Pinia/Naive-UI + messageStore.message('info', 'Valores de seletor de municípios foram atualizado para limites de gráfico') } } From b282465205f9144dc095c327d8ef2e431709c361 Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Wed, 7 Jan 2026 14:49:37 -0300 Subject: [PATCH 30/31] Update cities selector behavior with states selected --- .../main/card-components/sub-select.js | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/src/components/main/card-components/sub-select.js b/src/components/main/card-components/sub-select.js index f17fee1..8259209 100644 --- a/src/components/main/card-components/sub-select.js +++ b/src/components/main/card-components/sub-select.js @@ -157,21 +157,48 @@ export default defineComponent({ // We use setTimeout to run this code after Vue render process setTimeout(() => { - const allOptions = toRaw(citiesTemp.value) const selectLength = Array.isArray(cityTemp.value) ? cityTemp.value.length : null - if (selectLength === allOptions.length || uncheckAll) { + if (Array.isArray(form.value.local) && form.value.local.length) { + + if ( + Array.isArray(cityTemp.value) && + Array.isArray(citiesTemp.value) && + cityTemp.value.length === citiesTemp.value.length + ) { formValue.city = [] cityTemp.value = [] handleShowUpdate(true, field) isLoadingCities.value = false return - } + } - formValue.city = allCitiesValues - cityTemp.value = allCitiesValues + let result = /** @type{string[]} */ ([]) + form.value.local.forEach((/** @type{string} **/ state) => { + form.value.cities.filter((/** @type{{ uf: string }} */ item) => item.uf === state) + const cities = /** @type{string[]} */ ( + form.value.cities.filter((/** @type{{ uf: string }} */ item) => item.uf === state).map(city => city.codigo6) + ) + result.push(...cities) + }) + formValue.city = result + cityTemp.value = result + } else { + const allOptions = toRaw(citiesTemp.value) + + if (selectLength === allOptions.length || uncheckAll) { + formValue.city = [] + cityTemp.value = [] + handleShowUpdate(true, field) + isLoadingCities.value = false + return + } + + formValue.city = allCitiesValues + cityTemp.value = allCitiesValues + } handleShowUpdate(true, field) isLoadingCities.value = false From bc17abd867d2e4fdce2a93865953cab792d3a8dd Mon Sep 17 00:00:00 2001 From: Marcel Marques Date: Wed, 7 Jan 2026 14:50:02 -0300 Subject: [PATCH 31/31] Update state selector with clearable option --- src/components/main/card-components/sub-select.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/main/card-components/sub-select.js b/src/components/main/card-components/sub-select.js index 8259209..9a0c6ef 100644 --- a/src/components/main/card-components/sub-select.js +++ b/src/components/main/card-components/sub-select.js @@ -646,6 +646,7 @@ export default defineComponent({ v-model:value="localTemp" :options="form.locals" class="mct-select" + clearable :style="styleWidth" placeholder="Selecione Estado" multiple