diff --git a/package.json b/package.json index b713c906a..8568d507d 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "lint": "eslint --ext .js,.vue ./", "format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore", "test:e2e": "cypress open", - "test:e2e:ci": "cypress run --browser chromium", + "test:e2e:ci": "cypress run --browser chrome", "test": "echo \"See package.json => scripts for available tests.\" && exit 0", "test:unit": "vitest", "test:unit:ci": "vitest run" diff --git a/src/components/FetchData.vue b/src/components/FetchData.vue index 251d7502a..f0d908972 100644 --- a/src/components/FetchData.vue +++ b/src/components/FetchData.vue @@ -46,7 +46,7 @@ async function fetch() { if ($props.limit) filter.limit = $props.limit; const { data } = await axios.get($props.url, { - params: { filter }, + params: { filter: JSON.stringify(filter) }, }); emit('onFetch', data); diff --git a/src/components/common/VnJsonValue.vue b/src/components/common/VnJsonValue.vue new file mode 100644 index 000000000..a2e858d0d --- /dev/null +++ b/src/components/common/VnJsonValue.vue @@ -0,0 +1,88 @@ +<script setup> +import { watch } from 'vue'; +import { toDateString } from 'src/filters'; + +const props = defineProps({ + value: { type: [String, Number, Boolean, Object], default: undefined }, +}); + +const maxStrLen = 512; +let t = ''; +let cssClass = ''; +let type; +const updateValue = () => { + type = typeof props.value; + + if (props.value == null) { + t = '∅'; + cssClass = 'json-null'; + } else { + cssClass = `json-${type}`; + switch (type) { + case 'number': + if (Number.isInteger(props.value)) { + t = props.value.toString(); + } else { + t = ( + Math.round((props.value + Number.EPSILON) * 1000) / 1000 + ).toString(); + } + break; + case 'boolean': + t = props.value ? '✓' : '✗'; + cssClass = `json-${props.value ? 'true' : 'false'}`; + break; + case 'string': + t = + props.value.length <= maxStrLen + ? props.value + : props.value.substring(0, maxStrLen) + '...'; + break; + case 'object': + if (props.value instanceof Date) { + t = toDateString(props.value); + } else { + t = props.value.toString(); + } + break; + default: + t = props.value.toString(); + } + } +}; + +watch(() => props.value, updateValue); + +updateValue(); +</script> + +<template> + <span + :title="type === 'string' && props.value.length > maxStrLen ? props.value : ''" + :class="{ [cssClass]: t !== '' }" + > + {{ t }} + </span> +</template> + +<style scoped> +.json-string { + color: #d172cc; +} +.json-object { + color: #d1a572; +} +.json-number { + color: #85d0ff; +} +.json-true { + color: #7dc489; +} +.json-false { + color: #c74949; +} +.json-null { + color: #cd7c7c; + font-style: italic; +} +</style> diff --git a/src/components/common/VnLog.vue b/src/components/common/VnLog.vue index 1213c8bbc..7e3cfc408 100644 --- a/src/components/common/VnLog.vue +++ b/src/components/common/VnLog.vue @@ -1,128 +1,641 @@ <script setup> +import { ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute } from 'vue-router'; -import { useSession } from 'src/composables/useSession'; +import axios from 'axios'; import { useStateStore } from 'stores/useStateStore'; -import VnPaginate from 'src/components/ui/VnPaginate.vue'; -import VnLogFilter from 'src/components/common/VnLogFilter.vue'; - -import { toDate } from 'src/filters'; +import { useValidationsStore } from 'src/stores/useValidationsStore'; +import { toRelativeDate, toDateString, toDateHour } from 'src/filters'; +import { useColor } from 'src/composables/useColor'; +import { useFirstUpper } from 'src/composables/useFirstUpper'; +import { useIso8601 } from 'src/composables/useIso8601'; +import VnAvatar from '../ui/VnAvatar.vue'; +import VnJsonValue from '../common/VnJsonValue.vue'; +import FetchData from '../FetchData.vue'; +import WorkerDescriptorProxy from 'src/pages/Worker/Card/WorkerDescriptorProxy.vue'; const stateStore = useStateStore(); const route = useRoute(); -const session = useSession(); -const token = session.getToken(); const { t } = useI18n(); - +const validationsStore = useValidationsStore(); const props = defineProps({ model: { type: String, default: null, }, }); +const filter = { + fields: [ + 'id', + 'originFk', + 'userFk', + 'action', + 'changedModel', + 'oldInstance', + 'newInstance', + 'creationDate', + 'changedModel', + 'changedModelId', + 'changedModelValue', + 'description', + ], + include: [ + { + relation: 'user', + scope: { + fields: ['nickname', 'name', 'image'], + include: { + relation: 'worker', + scope: { + fields: ['id'], + }, + }, + }, + }, + ], +}; -const columns = [ - { - name: 'property', - label: 'Property', - field: (row) => t(`properties.${row.property}`), - align: 'left', - }, - { - name: 'before', - label: 'Before', - field: (row) => formatValue(row.before), - }, - { - name: 'after', - label: 'After', - field: (row) => formatValue(row.after), - }, +const workers = ref(); +const actions = ref(); +const changeInput = ref(); +const searchInput = ref(); +const userRadio = ref(); +const userSelect = ref(); +const date = ref(); +const dateDialog = ref(false); +const dateTo = ref(); +const dateToDialog = ref(false); +const selectedFilters = ref({}); +const userTypes = [ + { label: 'All', value: undefined }, + { label: 'User', value: { neq: null } }, + { label: 'System', value: null }, ]; +const checkboxOptions = ref({ + insert: { + label: 'Creates', + selected: false, + }, + update: { + label: 'Edits', + selected: false, + }, + delete: { + label: 'Deletes', + selected: false, + }, + select: { + label: 'Accesses', + selected: false, + }, +}); -function formatValue(value) { - if (typeof value === 'boolean') { - return value ? t('Yes') : t('No'); - } +let validations; +let pointRecord = ref(null); +let byRecord = ref(false); +const logTree = ref([]); - if (isNaN(value) && !isNaN(Date.parse(value))) { - return toDate(value); - } +const actionsText = { + insert: 'Creates', + update: 'Edits', + delete: 'Deletes', + select: 'Accesses', +}; +const actionsClass = { + insert: 'success', + update: 'warning', + delete: 'alert', + select: 'notice', +}; +const actionsIcon = { + insert: 'add', + update: 'edit', + delete: 'remove', + select: 'visibility', +}; +const validDate = new RegExp( + /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])/.source + + /T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(.[0-9]+)?(Z)?$/.source +); - if (value === undefined) { - return t('Nothing'); - } +const filteredActions = ref([]); +const filteredWorkers = ref([]); - return `"${value}"`; +function castJsonValue(value) { + return typeof value === 'string' && validDate.test(value) ? new Date(value) : value; } -function actionColor(action) { - if (action === 'insert') return 'positive'; - if (action === 'update') return 'positive'; - if (action === 'delete') return 'negative'; +function parseProps(propNames, locale, vals, olds) { + const props = []; + + for (const prop of propNames) { + if (prop.endsWith('$')) continue; + props.push({ + name: prop, + nameI18n: useFirstUpper(locale.columns?.[prop]) || prop, + val: getVal(vals, prop), + old: olds && getVal(olds, prop), + }); + } + props.sort((a, b) => a.nameI18n.localeCompare(b.nameI18n)); + + function getVal(vals, prop) { + let val; + let id; + const showProp = `${prop}$`; + + if (vals[showProp] != null) { + val = vals[showProp]; + id = vals[prop]; + } else val = vals[prop]; + + return { val: castJsonValue(val), id }; + } + + return props; } + +function getLogs(data) { + const logs = []; + let originLog = null; + let userLog = null; + let modelLog = null; + let prevLog, prevUser, prevModel; + let nLogs; + + data.forEach((log) => { + const locale = validationsStore.validations[log.changedModel]?.locale || {}; + + // Origin + if (!prevLog || prevLog.originFk != log.originFk) { + logs.push((originLog = { originFk: log.originFk, logs: [] })); + prevLog = log; + } + // User + if (prevUser != log.userFk) { + originLog.logs.push( + (userLog = { + user: log.user, + userFk: log.userFk, + logs: [], + }) + ); + prevUser = log.userFk; + } + // Model + if ( + !prevModel || + prevModel.changedModelId != log.changedModelId || + prevModel.changedModel != log.changedModel || + nLogs >= 6 + ) { + userLog.logs.push( + (modelLog = { + model: log.changedModel, + modelI18n: useFirstUpper(locale.name) || log.changedModel, + id: log.changedModelId, + showValue: log.changedModelValue, + logs: [], + }) + ); + prevModel = { + changedModelId: log.changedModelId, + changedModel: log.changedModel, + }; + nLogs = 0; + } + nLogs++; + modelLog.logs.push(log); + + // Changes + const notDelete = log.action != 'delete'; + const olds = (notDelete ? log.oldInstance : null) || {}; + const vals = (notDelete ? log.newInstance : log.oldInstance) || {}; + + let propNames = Object.keys(olds).concat(Object.keys(vals)); + propNames = [...new Set(propNames)]; + + log.props = parseProps(propNames, locale, vals, olds); + }); + return logs; +} + +async function openPointRecord(id, modelLog) { + pointRecord.value = null; + const { data } = await axios.get(`${props.model}Logs/${id}/pitInstance`); + const propNames = Object.keys(data); + const locale = validationsStore.validations[modelLog.model]?.locale || {}; + pointRecord.value = parseProps(propNames, locale, data); +} +async function setLogTree() { + if (!validations) { + validations = await validationsStore.fetchModels(); + } + filter.where = { and: [{ originFk: route.params.id }] }; + const { data } = await axios.get(`${props.model}Logs`, { + params: { filter: JSON.stringify(filter) }, + }); + logTree.value = getLogs(data); +} + +function filterByRecord(modelLog) { + byRecord.value = true; + const { id, model } = modelLog; + + searchInput.value = id; + selectedFilters.value.changedModelId = id; + selectedFilters.value.changedModel = model; + applyFilter(); +} + +async function applyFilter() { + filter.where = { and: [] }; + if ( + !selectedFilters.value.changedModel || + (!selectedFilters.value.changedModelValue && + !selectedFilters.value.changedModelId) + ) + byRecord.value = false; + + if (!byRecord.value) filter.where.and.push({ originFk: route.params.id }); + + if (Object.keys(selectedFilters.value).length) { + filter.where.and.push(selectedFilters.value); + } + + const { data } = await axios.get(`${props.model}Logs`, { + params: { filter: JSON.stringify(filter) }, + }); + + logTree.value = getLogs(data); +} + +function setDate(type) { + const from = date.value + ? useIso8601(date.value.split('-').reverse().join('-')) + : undefined; + const to = dateTo.value + ? useIso8601(`${dateTo.value.split('-').reverse().join('-')} 21:59:59.999`) + : useIso8601(`${date.value.split('-').reverse().join('-')} 21:59:59.999`); + + switch (type) { + case 'from': + return { between: [from, to] }; + case 'to': { + if (date.value) { + return { + between: [from, to], + }; + } else { + return { lte: to }; + } + } + } +} + +function selectFilter(type, dateType) { + const filter = {}; + const actions = { inq: [] }; + let reload = true; + + if (type === 'search') { + if (/^\s*[0-9]+\s*$/.test(searchInput.value) || props.byRecord) { + selectedFilters.value.changedModelId = searchInput.value.trim(); + } else if (!searchInput.value) { + selectedFilters.value.changedModelId = undefined; + selectedFilters.value.changedModelValue = undefined; + } else { + selectedFilters.value.changedModelValue = { like: `%${searchInput.value}%` }; + } + } + if (type === 'action' && selectedFilters.value.changedModel === null) { + selectedFilters.value.changedModel = undefined; + reload = false; + } + if (type === 'userRadio') { + selectedFilters.value.userFk = userRadio.value; + } + if (type === 'change') { + if (changeInput.value) + selectedFilters.value.or = [ + { oldJson: { like: `%${changeInput.value}%` } }, + { newJson: { like: `%${changeInput.value}%` } }, + { description: { like: `%${changeInput.value}%` } }, + ]; + else selectedFilters.value.or = undefined; + } + if (type === 'userSelect') { + selectedFilters.value.userFk = + userSelect.value !== null ? userSelect.value : undefined; + } + if (type === 'date') { + if (!date.value && !dateTo.value) { + selectedFilters.value.creationDate = undefined; + } else if (dateType === 'to') { + selectedFilters.value.creationDate = setDate('to'); + } else if (dateType === 'from') { + selectedFilters.value.creationDate = setDate('from'); + } + } + + Object.keys(checkboxOptions.value).forEach((key) => { + if (checkboxOptions.value[key].selected) actions.inq.push(key); + }); + selectedFilters.value.action = actions.inq.length ? actions : undefined; + + Object.keys(selectedFilters.value).forEach((key) => { + if (selectedFilters.value[key]) filter[key] = selectedFilters.value[key]; + }); + + if (reload) applyFilter(filter); +} + +async function clearFilter() { + selectedFilters.value = {}; + byRecord.value = false; + userSelect.value = undefined; + searchInput.value = undefined; + changeInput.value = undefined; + date.value = undefined; + dateTo.value = undefined; + Object.keys(checkboxOptions.value).forEach( + (opt) => (checkboxOptions.value[opt].selected = false) + ); + await applyFilter(); +} + +function filterFn(val, update, abortFn, type) { + if (!val) { + update(() => { + if (type === 'actions') filteredActions.value = actions.value; + if (type === 'workers') filteredWorkers.value = workers.value; + }); + return; + } + + update(() => { + const needle = val.toLowerCase(); + if (type === 'actions') + filteredActions.value = actions.value.filter((item) => + item.toLowerCase().includes(needle) + ); + if (type === 'workers') { + if (isNaN(needle)) + filteredWorkers.value = workers.value.filter( + (item) => + item.name.toLowerCase().includes(needle) || + item.nickname.toLowerCase().includes(needle) + ); + else + filteredWorkers.value = workers.value.filter((item) => item.id == needle); + } + }); +} + +setLogTree(); </script> <template> - <div class="column items-center"> - <QTimeline class="q-pa-md"> - <QTimelineEntry heading tag="h4"> {{ t('Audit logs') }} </QTimelineEntry> - <VnPaginate - :data-key="`${props.model}Logs`" - :url="`${props.model}s/${route.params.id}/logs`" - order="id DESC" - :offset="100" - :limit="5" - auto-load - > - <template #body="{ rows }"> - <template v-for="log of rows" :key="log.id"> - <QTimelineEntry - :avatar="`/api/Images/user/160x160/${log.userFk}/download?access_token=${token}`" - > - <template #subtitle> - {{ log.userName }} - - {{ - toDate(log.created, { - dateStyle: 'medium', - timeStyle: 'short', - }) - }} - </template> - <template #title> - <QChip :color="actionColor(log.action)"> - {{ t(`actions.${log.action}`) }} - </QChip> - {{ t(`models.${log.model}`) }} - </template> - <QTable - :rows="log.changes" - :columns="columns" - row-key="property" - hide-pagination + <FetchData + :url="`${props.model}Logs/${route.params.id}/editors`" + :filter="{ + fields: ['id', 'nickname', 'name', 'image'], + order: 'nickname', + limit: 30, + }" + @on-fetch="(data) => (workers = data)" + auto-load + /> + <FetchData + :url="`${props.model}Logs/${route.params.id}/models`" + :filter="{ order: ['changedModel'] }" + @on-fetch="(data) => (actions = data.map((item) => item.changedModel))" + auto-load + /> + <div + class="column items-center logs origin-log" + v-for="(originLog, originLogIndex) in logTree" + :key="originLogIndex" + > + <QItem class="origin-info items-center q-my-md" v-if="logTree.length > 1"> + <h6 class="origin-id"> + {{ originLog.modelI18n }} + #{{ originLog.originFk }} + </h6> + <div class="line"></div> + </QItem> + <div + class="user-log q-mb-sm row" + v-for="(userLog, userIndex) in originLog.logs" + :key="userIndex" + > + <div class="timeline"> + <div class="user-avatar"> + <VnAvatar + class="cursor-pointer" + :worker="userLog.user.id" + :title="userLog.user.nickname" + /> + <WorkerDescriptorProxy + v-if="userLog.user.image" + :id="userLog.user.id" + /> + </div> + <div class="arrow bg-panel" v-if="byRecord"></div> + <div class="line"></div> + </div> + <QList class="user-changes" v-if="userLog"> + <QItem + class="model-log column q-px-none q-py-xs" + v-for="(modelLog, modelLogIndex) in userLog.logs" + :key="modelLogIndex" + > + <QItemSection> + <QItemLabel class="model-info q-mb-xs" v-if="!byRecord"> + <QChip dense - flat + size="md" + class="model-name q-mr-xs text-white" + v-if=" + !(modelLog.changedModel && modelLog.changedModelId) && + modelLog.model + " + :style="{ + backgroundColor: useColor(modelLog.model), + }" + :title="modelLog.model" > - <template #header="propsLabel"> - <QTr :props="propsLabel"> - <QTh - v-for="col in propsLabel.cols" - :key="col.name" - :props="propsLabel" + {{ t(modelLog.modelI18n) }} + </QChip> + <span class="model-id" v-if="modelLog.id" + >#{{ modelLog.id }}</span + > + <span class="model-value" :title="modelLog.showValue"> + {{ modelLog.showValue }} + </span> + <QBtn + flat + round + color="grey" + class="q-mr-xs q-ml-auto" + size="sm" + icon="filter_alt" + :title="t('recordChanges')" + @click.stop="filterByRecord(modelLog)" + /> + </QItemLabel> + </QItemSection> + <QItemSection> + <QCard + class="changes-log q-py-none" + v-for="(log, logIndex) in modelLog.logs" + :key="logIndex" + > + <QCardSection class="change-info q-pa-none"> + <QItem + class="q-px-sm q-py-xs justify-between items-center" + > + <div + class="date text-grey text-caption q-mr-sm" + :title=" + toDateHour(log.creationDate) ?? + `date:'dd/MM/yyyy HH:mm:ss'` + " + > + {{ toRelativeDate(log.creationDate) }} + </div> + <div> + <QBtn + color="grey" + class="pit" + icon="preview" + flat + round + :title="t('pointRecord')" + padding="xs" + v-if="log.action != 'insert'" + @click.stop=" + openPointRecord(log.id, modelLog) + " > - {{ t(col.label) }} - </QTh> - </QTr> - </template> - </QTable> - </QTimelineEntry> - </template> - </template> - </VnPaginate> - </QTimeline> + <QPopupProxy> + <QCard v-if="pointRecord"> + <div + class="header q-px-sm q-py-xs q-ma-none text-white text-bold bg-primary" + > + {{ modelLog.modelI18n }} + <span v-if="modelLog.id" + >#{{ modelLog.id }}</span + > + </div> + <QCardSection + class="change-detail q-pa-sm" + > + <QItem + v-for="( + value, index + ) in pointRecord" + :key="index" + class="q-pa-none" + > + <span + class="json-field q-mr-xs text-grey" + :title="value.name" + > + {{ value.nameI18n }}: + </span> + <VnJsonValue + :value="value.val.val" + /> + </QItem> + </QCardSection> + </QCard> + </QPopupProxy> + </QBtn> + <QIcon + class="action q-ml-xs" + :class="actionsClass[log.action]" + :name="actionsIcon[log.action]" + :title=" + t(`actions.${actionsText[log.action]}`) + " + /> + </div> + </QItem> + </QCardSection> + <QCardSection + class="change-detail q-px-sm q-py-xs" + :class="{ expanded: log.expand }" + v-if="log.props.length || log.description" + > + <QIcon + class="cursor-pointer q-mr-md" + color="grey" + name="expand_more" + :title="t('globals.details')" + size="sm" + @click="log.expand = !log.expand" + /> + <QList v-if="log.props.length" class="attributes"> + <QItem v-if="!log.expand" class="q-pa-none text-grey"> + <span + v-for="(prop, propIndex) in log.props" + :key="propIndex" + class="basic-json" + > + <span class="json-field" :title="prop.name"> + {{ prop.nameI18n }}: + </span> + <VnJsonValue :value="prop.val.val" /> + <span v-if="propIndex < log.props.length - 1"> + , + </span> + </span> + </QItem> + <QItem + v-if="log.expand" + class="expanded-json column q-pa-none" + > + <div + v-for="(prop, prop2Index) in log.props" + :key="prop2Index" + class="q-pa-none text-grey" + > + <span class="json-field" :title="prop.name"> + {{ prop.nameI18n }}: + </span> + <VnJsonValue :value="prop.val.val" /> + <span v-if="prop.val.id" class="id-value"> + #{{ prop.val.id }} + </span> + <span v-if="log.action == 'update'"> + ← + <VnJsonValue :value="prop.old.val" /> + <span v-if="prop.old.id" class="id-value"> + #{{ prop.old.id }} + </span> + </span> + </div> + </QItem> + </QList> + <span v-if="!log.props.length" class="description"> + {{ log.description }} + </span> + </QCardSection> + </QCard> + </QItemSection> + </QItem> + </QList> + </div> </div> <Teleport v-if="stateStore.isHeaderMounted()" to="#actions-append"> <div class="row q-gutter-x-sm"> - <QBtn flat @click="stateStore.toggleRightDrawer()" round dense icon="menu"> + <QBtn + flat + @click.stop="stateStore.toggleRightDrawer()" + round + dense + icon="menu" + > <QTooltip bottom anchor="bottom right"> {{ t('globals.collapseMenu') }} </QTooltip> @@ -131,28 +644,414 @@ function actionColor(action) { </Teleport> <QDrawer v-model="stateStore.rightDrawer" show-if-above side="right" :width="300"> <QScrollArea class="fit text-grey-8"> - <VnLogFilter :data-key="`${props.model}Logs`" /> + <QList dense> + <QSeparator /> + <QItem class="q-mt-sm"> + <QInput + :label="t('globals.search')" + v-model="searchInput" + class="full-width" + clearable + clear-icon="close" + @keyup.enter="() => selectFilter('search')" + @focusout="() => selectFilter('search')" + @clear="() => selectFilter('search')" + > + <template #append> + <QIcon name="info" class="cursor-pointer"> + <QTooltip>{{ t('tooltips.search') }}</QTooltip> + </QIcon> + </template> + </QInput> + </QItem> + <QItem class="q-mt-sm"> + <QSelect + class="full-width" + v-model="selectedFilters.changedModel" + @update:model-value="selectFilter('action')" + :options="filteredActions" + :label="t('globals.entity')" + use-input + clearable + clear-icon="close" + @filter=" + (val, update, abortFn) => + filterFn(val, update, abortFn, 'actions') + " + @clear="() => selectFilter('action')" + > + <template #option="{ opt, index, itemProps }"> + <QItem :index="index" v-bind="itemProps"> + {{ t(`models.${opt}`) }} + </QItem> + </template> + <template #selected-item="{ opt }"> + {{ t(opt) }} + </template> + </QSelect> + </QItem> + <QItem class="q-mt-sm"> + <QOptionGroup + size="sm" + v-model="userRadio" + :options="userTypes" + color="primary" + @update:model-value="selectFilter('userRadio')" + right-label + > + <template #label="{ label }"> + {{ t(`Users.${label}`) }} + </template> + </QOptionGroup> + </QItem> + <QItem class="q-mt-sm"> + <QItemSection v-if="!workers"> + <QSkeleton type="QInput" class="full-width" /> + </QItemSection> + <QItemSection v-if="workers"> + <QSelect + :label="t('globals.user')" + v-model="userSelect" + @update:model-value="selectFilter('userSelect')" + :options="filteredWorkers" + option-value="id" + option-label="name" + emit-value + map-options + use-input + clearable + clear-icon="close" + @filter=" + (val, update, abortFn) => + filterFn(val, update, abortFn, 'workers') + " + > + <template #option="{ opt, itemProps }"> + <QItem + v-bind="itemProps" + class="q-pa-xs row items-center" + > + <QItemSection class="col-3 items-center"> + <VnAvatar :worker="opt.id" /> + </QItemSection> + <QItemSection class="col-9 justify-center"> + <span>{{ opt.name }}</span> + <span class="text-grey">{{ opt.nickname }}</span> + </QItemSection> + </QItem> + </template> + </QSelect> + </QItemSection> + </QItem> + <QItem class="q-mt-sm"> + <QInput + :label="t('globals.changes')" + v-model="changeInput" + class="full-width" + clearable + clear-icon="close" + @keyup.enter="selectFilter('change')" + @focusout="selectFilter('change')" + @clear="selectFilter('change')" + > + <template #append> + <QIcon name="info" class="cursor-pointer"> + <QTooltip max-width="250px">{{ + t('tooltips.changes') + }}</QTooltip> + </QIcon> + </template> + </QInput> + </QItem> + <QItem + :class="index == 'create' ? 'q-mt-md' : 'q-mt-xs'" + v-for="(checkboxOption, index) in checkboxOptions" + :key="index" + > + <QCheckbox + size="sm" + v-model="checkboxOption.selected" + :label="t(`actions.${checkboxOption.label}`)" + @update:model-value="selectFilter" + /> + </QItem> + <QItem class="q-mt-sm"> + <QInput + class="full-width" + :label="t('globals.date')" + @click="dateDialog = true" + @focus="(evt) => evt.target.blur()" + @clear="selectFilter('date', 'to')" + v-model="date" + clearable + clear-icon="close" + /> + </QItem> + <QItem class="q-mt-sm"> + <QInput + class="full-width" + :label="t('to')" + @click="dateToDialog = true" + @focus="(evt) => evt.target.blur()" + @clear="selectFilter('date', 'from')" + v-model="dateTo" + clearable + clear-icon="close" + /> + </QItem> + </QList> </QScrollArea> </QDrawer> + <QDialog v-model="dateDialog"> + <QDate + :years-in-month-view="false" + v-model="date" + dense + flat + minimal + @update:model-value=" + (value) => { + dateDialog = false; + const formatDate = toDateString(new Date(value)); + date = formatDate.split('-').reverse().join('-'); + selectFilter('date', 'from'); + } + " + /> + </QDialog> + <QDialog v-model="dateToDialog"> + <QDate + v-model="dateTo" + dense + flat + minimal + @update:model-value=" + (value) => { + dateToDialog = false; + const formatDate = toDateString(new Date(value)); + dateTo = formatDate.split('-').reverse().join('-'); + selectFilter('date', 'to'); + } + " + /> + </QDialog> + <QPageSticky position="bottom-right" :offset="[25, 25]"> + <QBtn + v-if="Object.values(selectedFilters).some((filter) => filter !== undefined)" + color="primary" + icon="filter_alt_off" + size="md" + round + @click="clearFilter" + /> + </QPageSticky> </template> - +<style lang="scss"> +.q-timeline__subtitle { + opacity: 1 !important; +} +.logs { + .vn-label-value { + .label { + margin-right: 5px; + color: var(--vn-label); + } + } + .q-avatar { + font-size: 38px; + } +} +</style> <style lang="scss" scoped> -.q-timeline { - width: 100%; - max-width: 80em; +.q-card { + background-color: var(--vn-gray); +} +.q-item { + min-height: 0px; +} + +.origin-log { + &:first-child > .origin-info { + margin-top: 0; + } + & > .origin-info { + gap: 6px; + + & > .origin-id { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin: 0; + } + & > .line { + flex-grow: 1; + background-color: $primary; + height: 2px; + } + } +} +.user-log { + width: 40em; + + & > .timeline { + position: relative; + padding-right: 5px; + width: 50px; + min-width: 38px; + & > .arrow { + height: 8px; + width: 8px; + position: absolute; + transform: rotateY(0deg) rotate(45deg); + top: 15px; + right: -4px; + z-index: 1; + } + & > .user-avatar { + padding: 8px 0; + margin-top: -8px; + position: sticky; + top: 64px; + } + & > .line { + position: absolute; + background-color: $primary; + width: 2px; + left: 23px; + z-index: -1; + top: 0; + bottom: -8px; + } + } + &:last-child > .timeline > .line { + display: none; + } + & > .user-changes { + flex-grow: 1; + overflow: hidden; + } +} +.model-log { + .model-info { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + .model-value { + font-style: italic; + } + .model-id { + color: var(--vn-label); + font-size: 0.9rem; + } + .q-btn { + float: right; + } + } +} +.changes-log { + overflow: hidden; + + &:last-child { + margin-bottom: 0; + } + .change-info { + overflow: hidden; + background-color: var(--vn-header); + & > .date { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + & > div { + white-space: nowrap; + .action { + color: black; + border-radius: 50%; + padding: 3px; + font-size: 18px; + + &.notice { + background-color: $info; + } + &.success { + background-color: $positive; + } + &.warning { + background-color: $warning; + } + &.alert { + background-color: $negative; + } + } + } + } + & > .change-detail { + background-color: var(--vn-gray); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + box-sizing: border-box; + & > .q-icon { + float: right; + transition-property: transform, background-color; + transition-duration: 150ms; + margin: 0; + } + &.expanded { + text-overflow: initial; + white-space: initial; + + & > .q-icon { + transform: rotate(180deg); + } + } + & > .no-changes { + font-style: italic; + } + } +} +.q-menu { + display: block; + + & > .loading { + display: flex; + justify-content: center; + } + & > .q-card { + min-width: 180px; + max-width: 400px; + + & > .header { + color: $dark; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } } </style> <i18n> en: + to: To + pointRecord: View record at this point in time + recordChanges: show all record changes + tooltips: + search: Search by id or concept + changes: Search by changes actions: - insert: Creates - update: Updates - delete: Deletes + Creates: Creates + Edits: Edits + Deletes: Deletes + Accesses: Accesses models: - Claim: Claim - ClaimDms: Document - ClaimBeginning: Claimed Sales - ClaimObservation: Observation + Claim: Reclamación + ClaimDms: Documento + ClaimBeginning: Comienzo + ClaimObservation: Observación + Users: + User: Usuario + All: Todo + System: Sistema properties: id: ID claimFk: Claim ID @@ -172,6 +1071,12 @@ en: responsibility: Responsibility packages: Packages es: + to: Hasta + pointRecord: Ver el registro en este punto + recordChanges: Mostrar todos los cambios realizados en el registro + tooltips: + search: Buscar por identificador o concepto + changes: Buscar por cambios. Los atributos deben buscarse por su nombre interno, para obtenerlo situar el cursor sobre el atributo. Audit logs: Registros de auditoría Property: Propiedad Before: Antes @@ -179,14 +1084,14 @@ es: Yes: Si Nothing: Nada actions: - insert: Crea - update: Actualiza - delete: Elimina - models: - Claim: Reclamación - ClaimDms: Documento - ClaimBeginning: Línea reclamada - ClaimObservation: Observación + Creates: Crea + Edits: Modifica + Deletes: Elimina + Accesses: Accede + Users: + User: Usuario + All: Todo + System: Sistema properties: id: ID claimFk: ID reclamación diff --git a/src/composables/useColor.js b/src/composables/useColor.js new file mode 100644 index 000000000..b325e985f --- /dev/null +++ b/src/composables/useColor.js @@ -0,0 +1,35 @@ +export function djb2a(string) { + let hash = 5381; + for (let i = 0; i < string.length; i++) + hash = ((hash << 5) + hash) ^ string.charCodeAt(i); + return hash >>> 0; +} + +export function useColor(value) { + return '#' + colors[djb2a(value || '') % colors.length]; +} + +const colors = [ + 'b5b941', // Yellow + 'ae9681', // Peach + 'd78767', // Salmon + 'cc7000', // Orange bright + 'e2553d', // Coral + '8B0000', // Red dark + 'de4362', // Red crimson + 'FF1493', // Ping intense + 'be39a2', // Pink light + 'b754cf', // Purple middle + 'a87ba8', // Pink + '8a69cd', // Blue lavender + 'ab20ab', // Purple dark + '00b5b8', // Turquoise + '1fa8a1', // Green ocean + '5681cf', // Blue steel + '3399fe', // Blue sky + '6d9c3e', // Green chartreuse + '51bb51', // Green lime + '518b8b', // Gray board + '7e7e7e', // Gray + '5d5d5d', // Gray dark +]; diff --git a/src/composables/useFirstUpper.js b/src/composables/useFirstUpper.js new file mode 100644 index 000000000..36378c05f --- /dev/null +++ b/src/composables/useFirstUpper.js @@ -0,0 +1,3 @@ +export function useFirstUpper(str) { + return str && str.charAt(0).toUpperCase() + str.substr(1); +} diff --git a/src/composables/useIso8601.js b/src/composables/useIso8601.js new file mode 100644 index 000000000..848e60de8 --- /dev/null +++ b/src/composables/useIso8601.js @@ -0,0 +1,18 @@ +export function useIso8601(dateString) { + // Crear un objeto Date a partir de la cadena de texto + const date = new Date(dateString); + + // Obtener los componentes de fecha y hora + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + const hours = String(date.getUTCHours()).padStart(2, '0'); + const minutes = String(date.getUTCMinutes()).padStart(2, '0'); + const seconds = String(date.getUTCSeconds()).padStart(2, '0'); + const milliseconds = String(date.getUTCMilliseconds()).padStart(3, '0'); + + // Formatear la cadena en el formato ISO 8601 + const formattedDate = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${milliseconds}Z`; + + return formattedDate; +} diff --git a/src/css/app.scss b/src/css/app.scss index 3c8cc50b6..9ed51c1c7 100644 --- a/src/css/app.scss +++ b/src/css/app.scss @@ -14,6 +14,10 @@ a { color: $orange-4; } +.rounded--full { + border-radius: 50%; +} + // Removes chrome autofill background input:-webkit-autofill, select:-webkit-autofill { @@ -32,10 +36,12 @@ body.body--light { --vn-text: #000000; --vn-gray: #f5f5f5; --vn-label: #5f5f5f; + --vn-header: #e9e9e9; } body.body--dark { --vn-text: #ffffff; --vn-gray: #313131; --vn-label: #a8a8a8; + --vn-header: #212121; } diff --git a/src/filters/index.js b/src/filters/index.js index 158ce1009..b0c441641 100644 --- a/src/filters/index.js +++ b/src/filters/index.js @@ -2,6 +2,7 @@ import toLowerCase from './toLowerCase'; import toDate from './toDate'; import toDateString from './toDateString'; import toDateHour from './toDateHour'; +import toRelativeDate from './toRelativeDate'; import toCurrency from './toCurrency'; import toPercentage from './toPercentage'; import toLowerCamel from './toLowerCamel'; @@ -13,6 +14,7 @@ export { toDate, toDateString, toDateHour, + toRelativeDate, toCurrency, toPercentage, dashIfEmpty, diff --git a/src/filters/toRelativeDate.js b/src/filters/toRelativeDate.js new file mode 100644 index 000000000..76e67dbea --- /dev/null +++ b/src/filters/toRelativeDate.js @@ -0,0 +1,32 @@ +import { useI18n } from 'vue-i18n'; + +export default function formatDate(dateVal) { + const { t } = useI18n(); + const today = new Date(); + if (dateVal == null) return ''; + + const date = new Date(dateVal); + const dateZeroTime = new Date(dateVal); + dateZeroTime.setHours(0, 0, 0, 0); + const diff = Math.trunc( + (today.getTime() - dateZeroTime.getTime()) / (1000 * 3600 * 24) + ); + let format; + if (diff === 0) format = t('globals.today'); + else if (diff === 1) format = t('globals.yesterday'); + else if (diff > 1 && diff < 7) { + const options = { weekday: 'short' }; + format = date.toLocaleDateString(t('globals.dateFormat'), options); + } else if (today.getFullYear() === date.getFullYear()) { + const options = { day: 'numeric', month: 'short' }; + format = date.toLocaleDateString(t('globals.dateFormat'), options); + } else { + const options = { year: 'numeric', month: '2-digit', day: '2-digit' }; + format = date.toLocaleDateString(t('globals.dateFormat'), options); + } + + // Formatear la hora en HH:mm + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + return `${format} ${hours}:${minutes}`; +} diff --git a/src/i18n/en/index.js b/src/i18n/en/index.js index d7519ba53..285fb778f 100644 --- a/src/i18n/en/index.js +++ b/src/i18n/en/index.js @@ -5,6 +5,9 @@ export default { en: 'English', }, language: 'Language', + entity: 'Entity', + user: 'User', + details: 'Details', collapseMenu: 'Collapse left menu', backToDashboard: 'Return to dashboard', notifications: 'Notifications', @@ -13,8 +16,11 @@ export default { pinnedModules: 'Pinned modules', darkMode: 'Dark mode', logOut: 'Log out', + date: 'Date', dataSaved: 'Data saved', dataDeleted: 'Data deleted', + search: 'Search', + changes: 'Changes', add: 'Add', create: 'Create', save: 'Save', @@ -36,6 +42,9 @@ export default { summary: { basicData: 'Basic data', }, + today: 'Today', + yesterday: 'Yesterday', + dateFormat: 'en-GB', }, errors: { statusUnauthorized: 'Access denied', diff --git a/src/i18n/es/index.js b/src/i18n/es/index.js index fc2c80f55..97001eab8 100644 --- a/src/i18n/es/index.js +++ b/src/i18n/es/index.js @@ -5,6 +5,9 @@ export default { en: 'Inglés', }, language: 'Idioma', + entity: 'Entidad', + user: 'Usuario', + details: 'Detalles', collapseMenu: 'Contraer menú lateral', backToDashboard: 'Volver al tablón', notifications: 'Notificaciones', @@ -13,8 +16,11 @@ export default { pinnedModules: 'Módulos fijados', darkMode: 'Modo oscuro', logOut: 'Cerrar sesión', + date: 'Fecha', dataSaved: 'Datos guardados', dataDeleted: 'Datos eliminados', + search: 'Buscar', + changes: 'Cambios', add: 'Añadir', create: 'Crear', save: 'Guardar', @@ -36,6 +42,9 @@ export default { summary: { basicData: 'Datos básicos', }, + today: 'Hoy', + yesterday: 'Ayer', + dateFormat: 'es-ES', }, errors: { statusUnauthorized: 'Acceso denegado', diff --git a/src/stores/useValidationsStore.js b/src/stores/useValidationsStore.js new file mode 100644 index 000000000..c658a90af --- /dev/null +++ b/src/stores/useValidationsStore.js @@ -0,0 +1,19 @@ +import axios from 'axios'; +import { defineStore } from 'pinia'; + +export const useValidationsStore = defineStore('validationsStore', { + state: () => ({ + validations: null, + }), + actions: { + async fetchModels() { + if (this.validations) return; + try { + const { data } = await axios.get('Schemas/modelinfo'); + this.validations = data; + } catch (error) { + console.error('Error al obtener las validaciones:', error); + } + }, + }, +}); diff --git a/test/cypress/integration/ClaimNotes.spec.js b/test/cypress/integration/ClaimNotes.spec.js index 5b52dd339..9f52c29e4 100644 --- a/test/cypress/integration/ClaimNotes.spec.js +++ b/test/cypress/integration/ClaimNotes.spec.js @@ -8,6 +8,7 @@ describe('ClaimNotes', () => { it('should add a new note', () => { const message = 'This is a new message.'; cy.get('.q-page-sticky button').click(); + cy.get('.q-page-sticky > div > button').click(); cy.get('.q-dialog .q-card__section:nth-child(2)').type(message); cy.get('.q-card__actions button:nth-child(2)').click(); cy.get('.q-card .q-card__section:nth-child(2)') diff --git a/test/cypress/integration/vnLog.spec.js b/test/cypress/integration/vnLog.spec.js new file mode 100644 index 000000000..3d6d9b95d --- /dev/null +++ b/test/cypress/integration/vnLog.spec.js @@ -0,0 +1,31 @@ +/// <reference types="cypress" /> +describe('ClaimNotes', () => { + beforeEach(() => { + cy.login('developer'); + cy.visit(`/#/claim/${1}/log`); + }); + + it('should have just one record', () => { + cy.get('.model-info .q-btn').eq(1).click(); + cy.get('.user-log .model-log').its('length').should('eq', 1); + cy.get('.q-page-sticky .q-btn').click(); + cy.get('.user-log .model-log').its('length').should('eq', 4); + }); + + it('should filter by insert actions', () => { + cy.get('.q-gutter-x-sm > .q-btn > .q-btn__content > .q-icon').click(); + cy.get('.q-checkbox__inner').eq(0).click(); + cy.get('.q-page > .q-drawer-container > .fullscreen').click(); + cy.get('.model-info .q-chip__content').eq(0).should('have.text', 'Document'); + cy.get('.model-info .q-chip__content').eq(1).should('have.text', 'Beginning'); + }); + + it('should show the point record', () => { + cy.get('.pit').eq(0).click(); + cy.get('.q-menu .q-card .header').should('have.text', 'Observation #1'); + cy.get('.q-menu .q-card .json-string').should( + 'have.text', + 'Waiting for customer' + ); + }); +}); diff --git a/test/cypress/integration/workerList.spec.js b/test/cypress/integration/workerList.spec.js index d76958367..8d4dd770d 100644 --- a/test/cypress/integration/workerList.spec.js +++ b/test/cypress/integration/workerList.spec.js @@ -8,17 +8,17 @@ describe('WorkerList', () => { it('should load workers', () => { cy.get('.card-list-body > .list-items > :nth-child(2) > .value > span') .eq(0) - .should('have.text', 'victorvd'); - cy.get('.card-list-body > .list-items > :nth-child(2) > .value > span') - .eq(1) .should('have.text', 'JessicaJones'); cy.get('.card-list-body > .list-items > :nth-child(2) > .value > span') - .eq(2) + .eq(1) .should('have.text', 'BruceBanner'); + cy.get('.card-list-body > .list-items > :nth-child(2) > .value > span') + .eq(2) + .should('have.text', 'CharlesXavier'); }); it('should open the worker summary', () => { - cy.get('.card-list-body .actions .q-btn:nth-child(2)').eq(1).click(); + cy.get('.card-list-body .actions .q-btn:nth-child(2)').eq(0).click(); cy.get('.summaryHeader div').should('have.text', '1110 - Jessica Jones'); cy.get('.summary .header').eq(0).invoke('text').should('include', 'Basic data'); cy.get('.summary .header').eq(1).should('have.text', 'User data');