<script setup> import { ref, onBeforeMount, onMounted, computed, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute, useRouter } from 'vue-router'; import { useQuasar } from 'quasar'; import { useStateStore } from 'stores/useStateStore'; import CrudModel from 'src/components/CrudModel.vue'; import FormModelPopup from 'components/FormModelPopup.vue'; import VnFilterPanel from 'components/ui/VnFilterPanel.vue'; import VnTableColumn from 'components/VnTable/VnColumn.vue'; import VnTableFilter from 'components/VnTable/VnFilter.vue'; import VnTableChip from 'components/VnTable/VnChip.vue'; import VnVisibleColumn from 'src/components/VnTable/VnVisibleColumn.vue'; import VnLv from 'components/ui/VnLv.vue'; import VnTableOrder from 'src/components/VnTable/VnOrder.vue'; const $props = defineProps({ columns: { type: Array, required: true, }, defaultMode: { type: String, default: 'table', // 'table', 'card' }, columnSearch: { type: Boolean, default: true, }, rightSearch: { type: Boolean, default: true, }, rowClick: { type: [Function, Boolean], default: null, }, rowCtrlClick: { type: [Function, Boolean], default: null, }, redirect: { type: String, default: null, }, create: { type: Object, default: null, }, createAsDialog: { type: Boolean, default: true, }, cardClass: { type: String, default: 'flex-one', }, searchUrl: { type: String, default: 'table', }, isEditable: { type: Boolean, default: false, }, useModel: { type: Boolean, default: false, }, hasSubToolbar: { type: Boolean, default: null, }, disableOption: { type: Object, default: () => ({ card: false, table: false }), }, withoutHeader: { type: Boolean, default: false, }, tableCode: { type: String, default: null, }, table: { type: Object, default: () => ({}), }, crudModel: { type: Object, default: () => ({}), }, tableHeight: { type: String, default: '90vh', }, footer: { type: Boolean, default: false, }, }); const { t } = useI18n(); const stateStore = useStateStore(); const route = useRoute(); const router = useRouter(); const quasar = useQuasar(); const CARD_MODE = 'card'; const TABLE_MODE = 'table'; const mode = ref(CARD_MODE); const selected = ref([]); const hasParams = ref(false); const routeQuery = JSON.parse(route?.query[$props.searchUrl] ?? '{}'); const params = ref({ ...routeQuery, ...routeQuery.filter?.where }); const orders = ref(parseOrder(routeQuery.filter?.order)); const CrudModelRef = ref({}); const showForm = ref(false); const splittedColumns = ref({ columns: [] }); const columnsVisibilitySkipped = ref(); const createForm = ref(); const tableModes = [ { icon: 'view_column', title: t('table view'), value: TABLE_MODE, disable: $props.disableOption?.table, }, { icon: 'grid_view', title: t('grid view'), value: CARD_MODE, disable: $props.disableOption?.card, }, ]; onBeforeMount(() => { setUserParams(route.query[$props.searchUrl]); hasParams.value = params.value && Object.keys(params.value).length !== 0; }); onMounted(() => { mode.value = quasar.platform.is.mobile && !$props.disableOption?.card ? CARD_MODE : $props.defaultMode; stateStore.rightDrawer = quasar.screen.gt.xs; columnsVisibilitySkipped.value = [ ...splittedColumns.value.columns .filter((c) => c.visible == false) .map((c) => c.name), ...['tableActions'], ]; createForm.value = $props.create; if ($props.create && route?.query?.createForm) { showForm.value = true; createForm.value = { ...createForm.value, ...{ formInitialData: JSON.parse(route?.query?.createForm) }, }; } }); watch( () => $props.columns, (value) => splitColumns(value), { immediate: true } ); watch( () => route.query[$props.searchUrl], (val) => setUserParams(val) ); const isTableMode = computed(() => mode.value == TABLE_MODE); function setUserParams(watchedParams, watchedOrder) { if (!watchedParams) return; if (typeof watchedParams == 'string') watchedParams = JSON.parse(watchedParams); const filter = typeof watchedParams?.filter == 'string' ? JSON.parse(watchedParams?.filter ?? '{}') : watchedParams?.filter; const where = filter?.where; const order = watchedOrder ?? filter?.order; watchedParams = { ...watchedParams, ...where }; delete watchedParams.filter; delete params.value?.filter; params.value = { ...params.value, ...sanitizer(watchedParams) }; orders.value = parseOrder(order); } function sanitizer(params) { for (const [key, value] of Object.entries(params)) { if (value && typeof value == 'object') { const param = Object.values(value)[0]; if (typeof param == 'string') params[key] = param.replaceAll('%', ''); } } return params; } function splitColumns(columns) { splittedColumns.value = { columns: [], chips: [], create: [], cardVisible: [], }; for (const col of columns) { if (col.name == 'tableActions') { col.orderBy = false; splittedColumns.value.actions = col; } if (col.chip) splittedColumns.value.chips.push(col); if (col.isTitle) splittedColumns.value.title = col; if (col.create) splittedColumns.value.create.push(col); if (col.cardVisible) splittedColumns.value.cardVisible.push(col); if ($props.isEditable && col.disable == null) col.disable = false; if ($props.useModel && col.columnFilter != false) col.columnFilter = { ...col.columnFilter, inWhere: true }; splittedColumns.value.columns.push(col); } // Status column if (splittedColumns.value.chips.length) { splittedColumns.value.columnChips = splittedColumns.value.chips.filter( (c) => !c.isId ); if (splittedColumns.value.columnChips.length) splittedColumns.value.columns.unshift({ align: 'left', label: t('status'), name: 'tableStatus', columnFilter: false, orderBy: false, }); } } const rowClickFunction = computed(() => { if ($props.rowClick != undefined) return $props.rowClick; if ($props.redirect) return ({ id }) => redirectFn(id); return () => {}; }); const rowCtrlClickFunction = computed(() => { if ($props.rowCtrlClick != undefined) return $props.rowCtrlClick; if ($props.redirect) return (evt, { id }) => { stopEventPropagation(evt); window.open(`/#/${$props.redirect}/${id}`, '_blank'); }; return () => {}; }); function redirectFn(id) { router.push({ path: `/${$props.redirect}/${id}` }); } function stopEventPropagation(event) { event.preventDefault(); event.stopPropagation(); } function reload(params) { selected.value = []; CrudModelRef.value.reload(params); } function columnName(col) { const column = { ...col, ...col.columnFilter }; let name = column.name; if (column.alias) name = column.alias + '.' + name; return name; } function getColAlign(col) { return 'text-' + (col.align ?? 'left'); } function parseOrder(urlOrders) { const orderObject = {}; if (!urlOrders) return orderObject; if (typeof urlOrders == 'string') urlOrders = [urlOrders]; for (const [index, orders] of urlOrders.entries()) { const [name, direction] = orders.split(' '); orderObject[name] = { direction, index: index + 1 }; } return orderObject; } const emit = defineEmits(['onFetch', 'update:selected', 'saveChanges']); defineExpose({ create: createForm, reload, redirect: redirectFn, selected, CrudModelRef, params, }); function handleOnDataSaved(_) { if (_.onDataSaved) _.onDataSaved(this); else $props.create.onDataSaved(_); } </script> <template> <QDrawer v-if="$props.rightSearch" v-model="stateStore.rightDrawer" side="right" :width="256" show-if-above > <QScrollArea class="fit"> <VnFilterPanel :data-key="$attrs['data-key']" :search-button="true" v-model="params" :search-url="searchUrl" :redirect="!!redirect" @set-user-params="setUserParams" > <template #body> <div class="row no-wrap flex-center" v-for="col of splittedColumns.columns.filter( (c) => c.columnFilter ?? true )" :key="col.id" > <VnTableFilter :column="col" :data-key="$attrs['data-key']" v-model="params[columnName(col)]" :search-url="searchUrl" /> <VnTableOrder v-if=" col?.columnFilter !== false && col?.name !== 'tableActions' " v-model="orders[col.orderBy ?? col.name]" :name="col.orderBy ?? col.name" :data-key="$attrs['data-key']" :search-url="searchUrl" :vertical="true" /> </div> <slot name="moreFilterPanel" :params="params" :columns="splittedColumns.columns" /> </template> </VnFilterPanel> </QScrollArea> </QDrawer> <!-- class in div to fix warn--> <CrudModel v-bind="$attrs" :class="$attrs['class'] ?? 'q-px-md'" :limit="$attrs['limit'] ?? 20" ref="CrudModelRef" @on-fetch="(...args) => emit('onFetch', ...args)" :search-url="searchUrl" :disable-infinite-scroll="isTableMode" @save-changes="reload" :has-sub-toolbar="$props.hasSubToolbar ?? isEditable" :auto-load="hasParams || $attrs['auto-load']" > <template v-for="(_, slotName) in $slots" #[slotName]="slotData" :key="slotName"> <slot :name="slotName" v-bind="slotData ?? {}" :key="slotName" /> </template> <template #body="{ rows }"> <QTable v-bind="table" class="vnTable" :columns="splittedColumns.columns" :rows="rows" v-model:selected="selected" :grid="!isTableMode" table-header-class="bg-header" card-container-class="grid-three" flat :style="isTableMode && `max-height: ${tableHeight}`" :virtual-scroll="isTableMode" @virtual-scroll=" (event) => event.index > rows.length - 2 && ($props.crudModel?.paginate ?? true) && CrudModelRef.vnPaginateRef.paginate() " @row-click="(_, row) => rowClickFunction && rowClickFunction(row)" @update:selected="emit('update:selected', $event)" > <template #top-left v-if="!$props.withoutHeader"> <slot name="top-left"></slot> </template> <template #top-right v-if="!$props.withoutHeader"> <VnVisibleColumn v-if="isTableMode" v-model="splittedColumns.columns" :table-code="tableCode ?? route.name" :skip="columnsVisibilitySkipped" /> <QBtnToggle v-model="mode" toggle-color="primary" class="bg-vn-section-color" dense :options="tableModes.filter((mode) => !mode.disable)" /> <QBtn v-if="$props.rightSearch" icon="filter_alt" class="bg-vn-section-color q-ml-sm" dense @click="stateStore.toggleRightDrawer()" /> </template> <template #header-cell="{ col }"> <QTh v-if="col.visible ?? true"> <div class="column self-start q-ml-xs ellipsis" :class="`text-${col?.align ?? 'left'}`" :style="$props.columnSearch ? 'height: 75px' : ''" > <div class="row items-center no-wrap" style="height: 30px"> <QTooltip v-if="col.toolTip">{{ col.toolTip }}</QTooltip> <VnTableOrder v-model="orders[col.orderBy ?? col.name]" :name="col.orderBy ?? col.name" :label="col?.label" :data-key="$attrs['data-key']" :search-url="searchUrl" /> </div> <VnTableFilter v-if="$props.columnSearch" :column="col" :show-title="true" :data-key="$attrs['data-key']" v-model="params[columnName(col)]" :search-url="searchUrl" class="full-width" /> </div> </QTh> </template> <template #header-cell-tableActions> <QTh auto-width class="sticky" /> </template> <template #body-cell-tableStatus="{ col, row }"> <QTd auto-width :class="getColAlign(col)"> <VnTableChip :columns="splittedColumns.columnChips" :row="row"> <template #afterChip> <slot name="afterChip" :row="row"></slot> </template> </VnTableChip> </QTd> </template> <template #body-cell="{ col, row, rowIndex }"> <!-- Columns --> <QTd auto-width class="no-margin q-px-xs" :class="[getColAlign(col), col.columnClass]" v-if="col.visible ?? true" @click.ctrl=" ($event) => rowCtrlClickFunction && rowCtrlClickFunction($event, row) " > <slot :name="`column-${col.name}`" :col="col" :row="row" :row-index="rowIndex" > <VnTableColumn :column="col" :row="row" :is-editable="col.isEditable ?? isEditable" v-model="row[col.name]" component-prop="columnField" /> </slot> </QTd> </template> <template #body-cell-tableActions="{ col, row }"> <QTd auto-width :class="getColAlign(col)" class="sticky no-padding" @click="stopEventPropagation($event)" > <QBtn v-for="(btn, index) of col.actions" v-show="btn.show ? btn.show(row) : true" :key="index" :title="btn.title" :icon="btn.icon" class="q-pa-xs" flat dense :class=" btn.isPrimary ? 'text-primary-light' : 'color-vn-text ' " :style="`visibility: ${ (btn.show && btn.show(row)) ?? true ? 'visible' : 'hidden' }`" @click="btn.action(row)" /> </QTd> </template> <template #item="{ row, colsMap }"> <component :is="$props.redirect ? 'router-link' : 'span'" :to="`/${$props.redirect}/` + row.id" > <QCard bordered flat class="row no-wrap justify-between cursor-pointer" @click=" (_, row) => { $props.rowClick && $props.rowClick(row); } " > <QCardSection vertical class="no-margin no-padding" :class="colsMap.tableActions ? 'w-80' : 'fit'" > <!-- Chips --> <QCardSection v-if="splittedColumns.chips.length" class="no-margin q-px-xs q-py-none" > <VnTableChip :columns="splittedColumns.chips" :row="row" > <template #afterChip> <slot name="afterChip" :row="row"></slot> </template> </VnTableChip> </QCardSection> <!-- Title --> <QCardSection v-if="splittedColumns.title" class="q-pl-sm q-py-none text-primary-light text-bold text-h6 cardEllipsis" > <span :title="row[splittedColumns.title.name]" @click="stopEventPropagation($event)" class="cursor-text" > {{ row[splittedColumns.title.name] }} </span> </QCardSection> <!-- Fields --> <QCardSection class="q-pl-sm q-pr-lg q-py-xs" :class="$props.cardClass" > <div v-for="( col, index ) of splittedColumns.cardVisible" :key="col.name" class="fields" > <VnLv :label=" !col.component && col.label ? `${col.label}:` : '' " > <template #value> <span @click="stopEventPropagation($event)" > <slot :name="`column-${col.name}`" :col="col" :row="row" :row-index="index" > <VnTableColumn :column="col" :row="row" :is-editable="false" v-model="row[col.name]" component-prop="columnField" :show-label="true" /> </slot> </span> </template> </VnLv> </div> </QCardSection> </QCardSection> <!-- Actions --> <QCardSection v-if="colsMap.tableActions" class="column flex-center w-10 no-margin q-pa-xs q-gutter-y-xs" @click="stopEventPropagation($event)" > <QBtn v-for="(btn, index) of splittedColumns.actions .actions" :key="index" :title="btn.title" :icon="btn.icon" class="q-pa-xs" flat :class=" btn.isPrimary ? 'text-primary-light' : 'color-vn-text ' " @click="btn.action(row)" /> </QCardSection> </QCard> </component> </template> <template #bottom-row="{ cols }" v-if="footer"> <QTr v-if="rows.length" class="bg-header" style="height: 30px"> <QTh v-for="col of cols.filter((cols) => cols.visible ?? true)" :key="col?.id" class="text-center" > <slot :name="`column-footer-${col.name}`" :class="getColAlign(col)" /> </QTh> </QTr> </template> </QTable> </template> </CrudModel> <QPageSticky v-if="$props.create" :offset="[20, 20]" style="z-index: 2"> <QBtn @click=" () => createAsDialog ? (showForm = !showForm) : handleOnDataSaved(create) " color="primary" fab icon="add" shortcut="+" /> <QTooltip> {{ createForm?.title }} </QTooltip> </QPageSticky> <QDialog v-model="showForm" transition-show="scale" transition-hide="scale"> <FormModelPopup v-bind="createForm" :model="$attrs['data-key'] + 'Create'" @on-data-saved="(_, res) => createForm.onDataSaved(res)" > <template #form-inputs="{ data }"> <div class="grid-create"> <slot v-for="column of splittedColumns.create" :key="column.name" :name="`column-create-${column.name}`" :data="data" :column-name="column.name" :label="column.label" > <VnTableColumn :column="column" :row="{}" default="input" v-model="data[column.name]" :show-label="true" component-prop="columnCreate" /> </slot> <slot name="more-create-dialog" :data="data" /> </div> </template> </FormModelPopup> </QDialog> </template> <i18n> en: status: Status table view: Table view grid view: Grid view es: status: Estados table view: Vista en tabla grid view: Vista en cuadrĂcula </i18n> <style lang="scss"> .bg-chip-secondary { background-color: var(--vn-page-color); color: var(--vn-text-color); } .bg-header { background-color: var(--vn-accent-color); color: var(--vn-text-color); } .color-vn-text { color: var(--vn-text-color); } .q-table--dark .q-table__bottom, .q-table--dark thead, .q-table--dark tr { border-color: var(--vn-section-color); } .grid-three { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, max-content)); max-width: 100%; grid-gap: 20px; margin: 0 auto; } .grid-create { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, max-content)); max-width: 100%; grid-gap: 20px; margin: 0 auto; } .flex-one { display: flex; flex-flow: row wrap; div.fields { width: 100%; .vn-label-value { display: flex; gap: 2%; } } } .q-table { th { padding: 0; } &__top { padding: 12px 0px; top: 0; } } .vnTable { thead tr th { position: sticky; z-index: 2; } thead tr:first-child th { top: 0; } .q-table__top { top: 0; padding: 12px 0; } tbody { .q-checkbox { display: flex; margin-bottom: 9px; & .q-checkbox__label { margin-left: 31px; color: var(--vn-text-color); } & .q-checkbox__inner { position: absolute; left: 0; color: var(--vn-label-color); } } } .sticky { position: sticky; right: 0; } td.sticky { background-color: var(--vn-section-color); z-index: 1; } } .vn-label-value { display: flex; flex-direction: row; color: var(--vn-text-color); .value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; pointer-events: all; cursor: text; user-select: all; } } .cardEllipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .grid-two { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, max-content)); max-width: 100%; margin: 0 auto; overflow: scroll; white-space: wrap; width: 100%; } .w-80 { width: 80%; } .w-20 { width: 20%; } .cursor-text { pointer-events: all; cursor: text; user-select: all; } </style>