salix-front/src/components/common/VnLog.vue

856 lines
32 KiB
Vue

<script setup>
import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { date } from 'quasar';
import { useStateStore } from 'stores/useStateStore';
import { toRelativeDate } from 'src/filters';
import { useColor } from 'src/composables/useColor';
import { useCapitalize } from 'src/composables/useCapitalize';
import { useValidator } from 'src/composables/useValidator';
import VnAvatar from '../ui/VnAvatar.vue';
import VnLogValue from './VnLogValue.vue';
import VnUserLink from '../ui/VnUserLink.vue';
import VnPaginate from '../ui/VnPaginate.vue';
import VnLogFilter from 'src/components/common/VnLogFilter.vue';
import RightMenu from './RightMenu.vue';
import { useFilterParams } from 'src/composables/useFilterParams';
const stateStore = useStateStore();
const validationsStore = useValidator();
const { models } = validationsStore;
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const props = defineProps({
model: {
type: String,
default: null,
},
url: {
type: String,
default: null,
},
mapper: {
type: Function,
default: null,
},
});
const filter = {
fields: [
'id',
'originFk',
'userFk',
'action',
'changedModel',
'oldInstance',
'newInstance',
'creationDate',
'changedModel',
'changedModelId',
'changedModelValue',
'description',
'summaryId',
],
include: [
{
relation: 'user',
scope: {
fields: ['nickname', 'name', 'image'],
include: {
relation: 'worker',
scope: {
fields: ['id'],
},
},
},
},
],
where: { and: [{ originFk: route.params.id }] },
};
const paginate = ref();
const dataKey = computed(() => `${props.model}Log`);
const userParams = ref(useFilterParams(dataKey.value).params);
let validations = models;
let pointRecord = ref(null);
let byRecord = ref(false);
const logTree = ref([]);
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,
);
function castJsonValue(value) {
return typeof value === 'string' && validDate.test(value) ? new Date(value) : value;
}
function parseProps(propNames, locale, vals, olds) {
const props = [];
for (const prop of propNames) {
if (prop.endsWith('$')) continue;
props.push({
name: prop,
nameI18n: useCapitalize(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 getLogTree(data) {
const logs = [];
let originLog = null;
let userLog = null;
let modelLog = null;
let nLogs;
for (let i = 0; i < data.length; i++) {
let log = data[i];
let prevLog = i > 0 ? data[i - 1] : null;
const locale = validations[log.changedModel]?.locale || {};
// Origin
const originChanged = !prevLog || log.originFk != prevLog.originFk;
if (originChanged) {
logs.push((originLog = { originFk: log.originFk, logs: [] }));
}
// User
const userChanged = originChanged || log.userFk != prevLog.userFk;
if (userChanged) {
originLog.logs.push(
(userLog = {
user: log.user,
userFk: log.userFk,
logs: [],
}),
);
}
// Model
const modelChanged =
userChanged ||
log.changedModel != prevLog.changedModel ||
log.changedModelId != prevLog.changedModelId ||
nLogs >= 6;
if (modelChanged) {
userLog.logs.push(
(modelLog = {
model: log.changedModel,
modelI18n: useCapitalize(locale.name) || log.changedModel,
id: log.changedModelId,
showValue: log.changedModelValue,
logs: [],
}),
);
nLogs = 0;
}
nLogs++;
modelLog.logs.push(log);
modelLog.summaryId = modelLog.logs[0].summaryId;
// 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 = validations[modelLog.model]?.locale || {};
pointRecord.value = parseProps(propNames, locale, data);
}
async function setLogTree(data) {
if (!data) return;
logTree.value = getLogTree(data);
}
function filterByRecord(modelLog) {
byRecord.value = true;
const { id, model } = modelLog;
applyFilter({ changedModelId: id, changedModel: model });
}
async function applyFilter(params = {}) {
paginate.value.arrayData.resetPagination();
paginate.value.arrayData.applyFilter({
filter: {},
params: { originFk: route.params.id, ...params },
});
}
function exprBuilder(param, value) {
switch (param) {
case 'changedModelValue':
return { [param]: { like: `%${value}%` } };
case 'change':
if (value)
return {
or: [
{ oldJson: { like: `%${value}%` } },
{ newJson: { like: `%${value}%` } },
{ description: { like: `%${value}%` } },
],
};
break;
case 'action':
if (value?.length) return { [param]: { inq: value } };
break;
case 'from':
return { creationDate: { gte: value } };
case 'to':
return { creationDate: { lte: value } };
case 'userType':
if (value === 'User') return { userFk: { neq: null } };
if (value === 'System') return { userFk: null };
break;
default:
return { [param]: value };
}
}
async function clearFilter() {
byRecord.value = false;
await applyFilter();
}
onMounted(() => {
stateStore.rightDrawerChangeValue(true);
});
onUnmounted(() => {
stateStore.rightDrawer = false;
});
watch(
() => router.currentRoute.value.params.id,
() => {
applyFilter();
},
);
</script>
<template>
<VnPaginate
ref="paginate"
:data-key
:url="dataKey + 's'"
:user-filter="filter"
:skeleton="false"
auto-load
@on-fetch="setLogTree"
@on-change="setLogTree"
search-url="logs"
:exprBuilder
:order="['creationDate DESC', 'id DESC']"
>
<template #body>
<div
class="column items-center logs origin-log q-mt-md"
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 text-grey">
{{ useCapitalize(validations[props.model].locale.name) }}
#{{ originLog.originFk }}
</h6>
<div class="line bg-grey"></div>
</QItem>
<div
class="user-log q-mb-sm"
v-for="(userLog, userIndex) in originLog.logs"
:key="userIndex"
>
<div class="timeline">
<div class="user-avatar">
<VnUserLink :worker-id="userLog?.user?.id">
<template #link>
<VnAvatar
:class="{ 'cursor-pointer': userLog?.user?.id }"
:worker-id="userLog?.user?.id"
:title="userLog?.user?.nickname"
:show-letter="!userLog?.user"
size="lg"
/>
</template>
</VnUserLink>
</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
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} #${modelLog.id}`"
data-cy="vnLog-model-chip"
>
{{ t(modelLog.modelI18n) }}
</QChip>
<span
class="model-id q-mr-xs"
v-if="modelLog.summaryId"
v-text="`#${modelLog.summaryId}`"
/>
<span
class="model-value"
:title="modelLog.showValue"
v-text="modelLog.showValue"
/>
<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 q-mb-xs"
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="
date.formatDate(
log.creationDate,
'DD/MM/YYYY hh:mm:ss',
) ?? `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="none"
v-if="log.action != 'insert'"
@click.stop="
openPointRecord(log.id, modelLog)
"
>
<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>
<VnLogValue
:value="value.val"
:name="value.name"
/>
</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]
}`,
)
"
data-cy="vnLog-action-icon"
/>
</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"
/>
<span v-if="log.props.length" class="attributes">
<span
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>
<VnLogValue
:value="prop.val"
:name="prop.name"
/>
<span
v-if="
propIndex <
log.props.length - 1
"
>,&nbsp;
</span>
</span>
</span>
<span
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>
<span v-if="log.action == 'update'">
<VnLogValue
:value="prop.old"
:name="prop.name"
/>
<span
v-if="prop.old.id"
class="id-value"
>
#{{ prop.old.id }}
</span>
<VnLogValue
:value="prop.val"
:name="prop.name"
/>
<span
v-if="prop.val.id"
class="id-value"
>
#{{ prop.val.id }}
</span>
</span>
<span v-else="prop.old.val">
<VnLogValue
:value="prop.val"
:name="prop.name"
/>
<span
v-if="prop.old.id"
class="id-value"
>#{{ prop.old.id }}</span
>
</span>
</div>
</span>
</span>
<span
v-if="!log.props.length"
class="description"
>
{{ log.description }}
</span>
</QCardSection>
</QCard>
</QItemSection>
</QItem>
</QList>
</div>
</div>
</template>
</VnPaginate>
<RightMenu>
<template #right-panel>
<VnLogFilter :data-key />
</template>
</RightMenu>
<QPageSticky position="bottom-right" :offset="[25, 25]">
<QBtn
v-if="Object.keys(userParams).some((filter) => filter !== 'originFk')"
color="primary"
icon="filter_alt_off"
size="md"
round
@click="clearFilter"
/>
</QPageSticky>
</template>
<style lang="scss" scoped>
.q-card {
background-color: var(--vn-section-color);
}
.q-item {
min-height: 0px;
}
.q-menu {
display: block;
& > .loading {
display: flex;
justify-content: center;
}
& > .q-card {
min-width: 180px;
max-width: 400px;
& > .header {
color: var(--vn-section-color);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.origin-log {
&:first-child > .origin-info {
margin-top: 0;
}
& > .origin-info {
margin-top: 28px;
gap: 6px;
& > .origin-id {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin: 0;
}
& > .line {
flex-grow: 1;
height: 2px;
}
}
}
.user-log {
display: flex;
width: 100%;
max-width: 40em;
& > .timeline {
position: relative;
padding-right: 1px;
width: 38px;
min-width: 38px;
flex-grow: auto;
& > .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: 19px;
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;
min-height: 22px;
.model-value {
font-style: italic;
}
.model-id {
color: var(--vn-label-color);
font-size: 0.9rem;
}
.q-btn {
visibility: hidden;
float: right;
}
}
&:hover {
.model-info {
.q-btn {
visibility: visible;
}
}
}
}
.changes-log {
width: 100%;
overflow: hidden;
&:last-child {
margin-bottom: 0;
}
.change-info {
overflow: hidden;
background-color: var(--vn-section-color);
& > .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;
}
}
}
.q-btn.pit {
visibility: hidden;
}
&:hover .q-btn.pit {
visibility: visible;
}
}
& > .change-detail {
position: relative;
overflow: hidden;
text-overflow: ellipsis;
background-color: var(--vn-section-color);
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;
}
}
}
</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:
Creates: Creates
Edits: Edits
Deletes: Deletes
Accesses: Accesses
Users:
User: User
All: All
System: System
properties:
id: ID
claimFk: Claim ID
saleFk: Sale ID
quantity: Quantity
observation: Observation
ticketCreated: Created
created: Created
isChargedToMana: Charged to mana
pickup: Type of pickup
dmsFk: Document ID
text: Description
claimStateFk: Claim State
workerFk: Worker
clientFk: Customer
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: Historial
Property: Propiedad
Before: Antes
After: Después
Yes: Si
Nothing: Nada
actions:
Creates: Crea
Edits: Modifica
Deletes: Elimina
Accesses: Accede
Users:
User: Usuario
All: Todo
System: Sistema
properties:
id: ID
claimFk: ID reclamación
saleFk: ID linea de venta
quantity: Cantidad
observation: Observación
ticketCreated: Creado
created: Creado
isChargedToMana: Cargado a maná
pickup: Se debe recoger
dmsFk: ID documento
text: Descripción
claimStateFk: Estado de la reclamación
workerFk: Trabajador
clientFk: Cliente
responsibility: Responsabilidad
packages: Bultos
</i18n>