Add per-user saved column visibility for tables

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-05 11:56:49 +03:30
co-authored by Cursor
parent 75ff2c36c4
commit 23c83ffbee
29 changed files with 624 additions and 23 deletions
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "UserTablePreference" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"tableKey" TEXT NOT NULL,
"hiddenColumns" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserTablePreference_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "UserTablePreference_userId_organizationId_tableKey_key" ON "UserTablePreference"("userId", "organizationId", "tableKey");
CREATE INDEX IF NOT EXISTS "UserTablePreference_userId_organizationId_idx" ON "UserTablePreference"("userId", "organizationId");
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "UserTablePreference" ADD CONSTRAINT "UserTablePreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
+17
View File
@@ -264,6 +264,7 @@ model User {
billingAccount BillingAccount?
auditLogs AuditLog[] @relation("AuditLogActor")
savedViews SavedView[] @relation("SavedViewOwner")
tablePreferences UserTablePreference[]
assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees")
teamMemberships TeamMember[]
reservationAssignments ReservationAppointment[] @relation("ReservationAssignee")
@@ -1712,6 +1713,22 @@ model AuditLog {
}
/// فیلترهای ذخیره‌شده برای لیست‌ها و کاریزها
/// ستون‌های مخفی هر جدول برای هر کاربر (به‌ازای هر سازمان)
model UserTablePreference {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
organizationId String
tableKey String
/// ستون جدیدی که بعداً اضافه شود به‌صورت پیش‌فرض نمایش داده می‌شود
hiddenColumns String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, organizationId, tableKey])
@@index([userId, organizationId])
}
model SavedView {
id String @id @default(cuid())
organizationId String
+2
View File
@@ -37,6 +37,7 @@ import { PlatformModule } from './platform/platform.module';
import { BillingModule } from './billing/billing.module';
import { AuditLogsModule } from './audit-logs/audit-logs.module';
import { SavedViewsModule } from './saved-views/saved-views.module';
import { TablePrefsModule } from './table-prefs/table-prefs.module';
import { PublicApiModule } from './public-api/public-api.module';
import { SmsCampaignsModule } from './sms-campaigns/sms-campaigns.module';
import { TeamsModule } from './teams/teams.module';
@@ -89,6 +90,7 @@ import { ConversationsModule } from './conversations/conversations.module';
TahesabModule,
AuditLogsModule,
SavedViewsModule,
TablePrefsModule,
PublicApiModule,
SmsCampaignsModule,
TrackingModule,
@@ -0,0 +1,92 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Put,
UseGuards,
} from '@nestjs/common';
import {
ArrayMaxSize,
IsArray,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { PrismaService } from '../prisma/prisma.service';
const TABLE_KEY_RE = /^[a-z0-9_.-]{1,80}$/i;
class SaveTablePrefDto {
@IsArray()
@ArrayMaxSize(80)
@IsString({ each: true })
@MaxLength(80, { each: true })
hiddenColumns!: string[];
}
class TableKeyParam {
@Matches(TABLE_KEY_RE)
key!: string;
}
type Actor = { userId: string; organizationId: string };
@Controller('table-prefs')
@UseGuards(JwtAuthGuard)
export class TablePrefsController {
constructor(private readonly prisma: PrismaService) {}
@Get()
async list(@CurrentUser() user: Actor) {
const rows = await this.prisma.userTablePreference.findMany({
where: { userId: user.userId, organizationId: user.organizationId },
select: { tableKey: true, hiddenColumns: true },
});
const out: Record<string, string[]> = {};
for (const row of rows) out[row.tableKey] = row.hiddenColumns;
return out;
}
@Put(':key')
async save(
@CurrentUser() user: Actor,
@Param() params: TableKeyParam,
@Body() dto: SaveTablePrefDto,
) {
const hiddenColumns = [...new Set(dto.hiddenColumns.map((c) => c.trim()).filter(Boolean))];
const row = await this.prisma.userTablePreference.upsert({
where: {
userId_organizationId_tableKey: {
userId: user.userId,
organizationId: user.organizationId,
tableKey: params.key,
},
},
create: {
userId: user.userId,
organizationId: user.organizationId,
tableKey: params.key,
hiddenColumns,
},
update: { hiddenColumns },
select: { tableKey: true, hiddenColumns: true },
});
return row;
}
@Delete(':key')
async reset(@CurrentUser() user: Actor, @Param() params: TableKeyParam) {
await this.prisma.userTablePreference.deleteMany({
where: {
userId: user.userId,
organizationId: user.organizationId,
tableKey: params.key,
},
});
return { ok: true };
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { TablePrefsController } from './table-prefs.controller';
@Module({
imports: [AuthModule],
controllers: [TablePrefsController],
})
export class TablePrefsModule {}
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import { useTableColumns } from '@/lib/tableColumns';
import { toFaDigits } from '@/lib/jalali';
const props = withDefaults(
defineProps<{
tableKey: string;
label?: string;
align?: 'start' | 'end';
}>(),
{ label: 'ستون‌ها', align: 'start' },
);
const open = ref(false);
const root = ref<HTMLElement | null>(null);
const cols = useTableColumns(() => props.tableKey);
const columns = computed(() => cols.columns());
const hiddenCount = computed(() => cols.hiddenCount());
function onDocClick(e: MouseEvent) {
if (open.value && root.value && !root.value.contains(e.target as Node)) open.value = false;
}
onMounted(() => document.addEventListener('click', onDocClick));
onBeforeUnmount(() => document.removeEventListener('click', onDocClick));
</script>
<template>
<div ref="root" class="tc-pop">
<button
class="tc-tool"
type="button"
:class="{ active: hiddenCount > 0 }"
:title="label"
@click.stop="open = !open"
>
<UiIcon name="sliders" :size="16" />
<span class="tc-label">{{ label }}</span>
<em v-if="hiddenCount" class="tc-badge">{{ toFaDigits(hiddenCount) }} مخفی</em>
</button>
<div v-if="open" class="tc-menu" :class="align" @click.stop>
<p class="tc-head">نمایش ستون‌ها</p>
<p v-if="!columns.length" class="tc-empty">ستونی برای نمایش وجود ندارد.</p>
<label v-for="col in columns" :key="col" class="tc-item">
<input type="checkbox" :checked="cols.isVisible(col)" @change="cols.toggle(col)" />
<span>{{ col }}</span>
</label>
<button v-if="hiddenCount" type="button" class="tc-reset" @click="cols.showAll()">
نمایش همه ستون‌ها
</button>
</div>
</div>
</template>
<style scoped>
.tc-pop {
position: relative;
}
.tc-tool {
min-height: var(--control-h, 2.4rem);
height: var(--control-h, 2.4rem);
border: 1px solid var(--line);
background: #fff;
border-radius: var(--radius);
padding: 0 0.75rem;
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 0.35rem;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink-secondary);
cursor: pointer;
white-space: nowrap;
}
.tc-tool:hover {
background: var(--surface-subtle);
border-color: var(--line-strong);
}
.tc-tool.active {
border-color: #5eead4;
color: #0f766e;
}
.tc-badge {
font-style: normal;
font-size: 0.72rem;
background: #f0fdfa;
color: #0f766e;
border-radius: 999px;
padding: 0.05rem 0.45rem;
}
.tc-menu {
position: absolute;
top: calc(100% + 6px);
inset-inline-start: 0;
z-index: 20;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.14);
padding: 0.45rem;
min-width: 13rem;
max-width: calc(100vw - 1.5rem);
max-height: min(24rem, 70vh);
overflow-y: auto;
display: grid;
gap: 0.1rem;
}
.tc-menu.end {
inset-inline-start: auto;
inset-inline-end: 0;
}
.tc-head {
margin: 0 0 0.25rem;
padding: 0.25rem 0.5rem;
font-size: var(--fs-xs, 0.75rem);
color: var(--ink-muted, #64748b);
font-weight: 700;
}
.tc-empty {
margin: 0;
padding: 0.4rem 0.5rem;
font-size: var(--fs-sm);
color: var(--ink-muted, #64748b);
}
.tc-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.42rem 0.5rem;
border-radius: var(--radius);
cursor: pointer;
font-size: var(--fs-sm);
user-select: none;
}
.tc-item:hover {
background: #f0fdfa;
}
.tc-item input {
width: 1rem;
height: 1rem;
accent-color: #0d9488;
margin: 0;
}
.tc-reset {
margin-top: 0.3rem;
border: 0;
border-top: 1px solid var(--line);
background: none;
padding: 0.5rem;
font: inherit;
font-size: var(--fs-sm);
color: #0f766e;
cursor: pointer;
text-align: start;
}
@media (max-width: 640px) {
.tc-label {
display: none;
}
}
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import UiIcon from '@/components/UiIcon.vue';
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
import {
@@ -36,14 +37,18 @@ function money(deal: PipelineDeal) {
}
const hideAmount = computed(() => Boolean(props.copy.hideAmount));
const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline.deals'));
</script>
<template>
<div class="list" dir="rtl">
<p v-if="!rows.length" class="empty">{{ copy.empty }}</p>
<div v-else class="table-wrap">
<table class="data-table">
<div v-if="rows.length" class="tc-row">
<TableColumnsMenu :table-key="tableKey" align="end" />
</div>
<div v-if="rows.length" class="table-wrap">
<table v-table-cols="tableKey" class="data-table">
<thead>
<tr>
<th>{{ copy.listColTitle }}</th>
+189
View File
@@ -0,0 +1,189 @@
import { reactive, watch, watchEffect, type Directive } from 'vue';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
/**
* نمایش/مخفی‌سازی ستون‌های جدول به‌ازای هر کاربر.
* ستون‌ها با متن سرستون (th) شناسایی می‌شوند تا ستون جدید به‌طور پیش‌فرض دیده شود.
*/
const STORAGE_PREFIX = 'mento_table_cols:';
const SAVE_DEBOUNCE_MS = 400;
const hiddenByTable = reactive<Record<string, string[]>>({});
const labelsByTable = reactive<Record<string, string[]>>({});
let activeScope: string | null = null;
let initialized = false;
const saveTimers = new Map<string, ReturnType<typeof setTimeout>>();
function storageKey(scope: string) {
return `${STORAGE_PREFIX}${scope}`;
}
function readCache(scope: string): Record<string, string[]> {
try {
const raw = localStorage.getItem(storageKey(scope));
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
if (parsed && typeof parsed === 'object') return parsed as Record<string, string[]>;
} catch {
/* ignore corrupted cache */
}
return {};
}
function writeCache() {
if (!activeScope) return;
try {
localStorage.setItem(storageKey(activeScope), JSON.stringify(hiddenByTable));
} catch {
/* storage full / disabled */
}
}
function replaceHidden(next: Record<string, string[]>) {
for (const key of Object.keys(hiddenByTable)) delete hiddenByTable[key];
for (const [key, list] of Object.entries(next)) {
if (Array.isArray(list)) hiddenByTable[key] = list.filter((v) => typeof v === 'string');
}
}
async function loadScope(scope: string) {
replaceHidden(readCache(scope));
try {
const { data } = await api.get<Record<string, string[]>>('/table-prefs');
if (activeScope !== scope) return;
replaceHidden(data ?? {});
writeCache();
} catch {
/* offline: keep cached */
}
}
function ensureInit() {
if (initialized) return;
initialized = true;
const auth = useAuthStore();
watch(
() => (auth.user ? `${auth.user.id}:${auth.user.organizationId}` : null),
(scope) => {
activeScope = scope;
if (scope) void loadScope(scope);
else replaceHidden({});
},
{ immediate: true },
);
installStyle();
}
function cssEscape(value: string) {
return typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(value) : value.replace(/"/g, '\\"');
}
function installStyle() {
const style = document.createElement('style');
style.id = 'mento-table-cols';
document.head.appendChild(style);
watchEffect(() => {
const rules: string[] = [];
for (const [table, labels] of Object.entries(labelsByTable)) {
const hidden = new Set(hiddenByTable[table] ?? []);
if (!hidden.size) continue;
labels.forEach((label, idx) => {
if (!label || !hidden.has(label)) return;
const sel = `table[data-tc="${cssEscape(table)}"]`;
const n = idx + 1;
rules.push(
`${sel} > thead > tr > th:nth-child(${n}),${sel} > tbody > tr > td:nth-child(${n}):not([colspan]),${sel} > tfoot > tr > td:nth-child(${n}):not([colspan]){display:none}`,
);
});
}
style.textContent = rules.join('\n');
});
}
function persist(table: string) {
writeCache();
const prev = saveTimers.get(table);
if (prev) clearTimeout(prev);
saveTimers.set(
table,
setTimeout(() => {
saveTimers.delete(table);
void api
.put(`/table-prefs/${encodeURIComponent(table)}`, {
hiddenColumns: hiddenByTable[table] ?? [],
})
.catch(() => undefined);
}, SAVE_DEBOUNCE_MS),
);
}
function readHeaderLabels(table: HTMLTableElement): string[] {
const rows = table.tHead?.rows;
const row = rows && rows.length ? rows[rows.length - 1] : null;
if (!row) return [];
return Array.from(row.cells).map((cell) =>
(cell.getAttribute('data-col') || cell.textContent || '').replace(/\s+/g, ' ').trim(),
);
}
function syncLabels(el: HTMLElement, key: string) {
if (!(el instanceof HTMLTableElement)) return;
el.dataset.tc = key;
const labels = readHeaderLabels(el);
const prev = labelsByTable[key];
if (!prev || prev.length !== labels.length || prev.some((v, i) => v !== labels[i])) {
labelsByTable[key] = labels;
}
}
/** روی `<table v-table-cols="'contacts.people'">` قرار می‌گیرد. */
export const vTableCols: Directive<HTMLElement, string> = {
mounted(el, binding) {
ensureInit();
syncLabels(el, binding.value);
},
updated(el, binding) {
syncLabels(el, binding.value);
},
};
declare module 'vue' {
interface ComponentCustomProperties {
vTableCols: typeof vTableCols;
}
}
export function useTableColumns(table: () => string) {
ensureInit();
const columns = () =>
(labelsByTable[table()] ?? []).filter((label, idx, all) => label && all.indexOf(label) === idx);
const isVisible = (label: string) => !(hiddenByTable[table()] ?? []).includes(label);
const hiddenCount = () => {
const visibleLabels = new Set(columns());
return (hiddenByTable[table()] ?? []).filter((l) => visibleLabels.has(l)).length;
};
function toggle(label: string) {
const key = table();
const hidden = new Set(hiddenByTable[key] ?? []);
if (hidden.has(label)) {
hidden.delete(label);
} else {
const remaining = columns().filter((c) => c !== label && !hidden.has(c));
if (!remaining.length) return;
hidden.add(label);
}
hiddenByTable[key] = [...hidden];
persist(key);
}
function showAll() {
const key = table();
hiddenByTable[key] = [];
persist(key);
}
return { columns, isVisible, hiddenCount, toggle, showAll };
}
+2
View File
@@ -3,6 +3,7 @@ import { createPinia } from 'pinia';
import { brand } from '@mento/shared';
import App from './App.vue';
import { router } from './router';
import { vTableCols } from './lib/tableColumns';
import './styles/main.css';
document.title = `${brand.nameFa} | ${brand.fullName}`;
@@ -12,4 +13,5 @@ if (favicon) favicon.href = brand.logo;
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.directive('table-cols', vTableCols);
app.mount('#app');
+6
View File
@@ -263,6 +263,12 @@ textarea::placeholder {
font-size: 0.9rem;
}
.tc-row {
display: flex;
justify-content: flex-end;
margin: 0 0 0.5rem;
}
@media (max-width: 900px) {
:root {
--mobile-tabbar-h: calc(3.65rem + env(safe-area-inset-bottom, 0px));
+5 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -72,7 +73,10 @@ onMounted(load);
<p v-else-if="loading && !items.length" class="muted">در حال بارگذاری…</p>
<p v-else-if="!items.length" class="muted">پیوستی ثبت نشده.</p>
<div v-else class="table-wrap">
<table>
<div class="tc-row">
<TableColumnsMenu table-key="attachments" align="end" />
</div>
<table v-table-cols="'attachments'">
<thead>
<tr>
<th>نام فایل</th>
+5 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type { AuditLogListResponse } from '@mento/shared';
@@ -112,8 +113,11 @@ onMounted(() => {
/>
<UiCard v-else-if="data?.items.length" title="رویدادها">
<div class="tc-row">
<TableColumnsMenu table-key="audit-logs" align="end" />
</div>
<div class="table-wrap">
<table>
<table v-table-cols="'audit-logs'">
<thead>
<tr>
<th>زمان</th>
+5 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -234,7 +235,10 @@ onMounted(load);
}}
</p>
<div v-else class="table-wrap">
<table>
<div class="tc-row">
<TableColumnsMenu table-key="cheques" align="end" />
</div>
<table v-table-cols="'cheques'">
<thead>
<tr>
<th>شماره</th>
+5 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink } from 'vue-router';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
@@ -163,7 +164,10 @@ onMounted(load);
/>
<div v-else class="crm-table-container">
<table class="crm-table">
<div class="tc-row">
<TableColumnsMenu table-key="commissions" align="end" />
</div>
<table v-table-cols="'commissions'" class="crm-table">
<thead>
<tr>
<th>کارشناس فروش</th>
+4 -2
View File
@@ -20,6 +20,7 @@ import UiIcon from '@/components/UiIcon.vue';
import PersonFormView from '@/views/PersonFormView.vue';
import CompanyFormView from '@/views/CompanyFormView.vue';
import SavedFiltersBar from '@/components/SavedFiltersBar.vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import type {
CompanyDto,
ImportContactsResult,
@@ -455,6 +456,7 @@ onMounted(load);
</li>
</ul>
</div>
<TableColumnsMenu :table-key="tab === 'people' ? 'contacts.people' : 'contacts.companies'" />
</div>
</div>
@@ -546,7 +548,7 @@ onMounted(load);
</div>
<div v-if="people?.items.length" class="table-wrap">
<table class="data-table">
<table v-table-cols="'contacts.people'" class="data-table">
<thead>
<tr>
<th>وضعیت</th>
@@ -655,7 +657,7 @@ onMounted(load);
</article>
</div>
<div v-if="companies?.items.length" class="table-wrap">
<table class="data-table">
<table v-table-cols="'contacts.companies'" class="data-table">
<thead>
<tr>
<th>نام شرکت</th>
+8 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
@@ -180,10 +181,16 @@ onMounted(load);
<div class="section-title">
<UiIcon name="box" :size="18" />
<h3>موجودی کالا</h3>
<TableColumnsMenu
v-if="summary.products.length"
table-key="inventory.stock"
align="end"
style="margin-inline-start: auto"
/>
</div>
</template>
<p v-if="!summary.products.length" class="muted empty-note">محصولی در انبار تعریف نشده است.</p>
<table v-else class="crm-table">
<table v-else v-table-cols="'inventory.stock'" class="crm-table">
<thead>
<tr>
<th>نام کالا</th>
+5 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -461,8 +462,11 @@ onMounted(load);
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<template v-else>
<div class="tc-row desk">
<TableColumnsMenu table-key="invoices" align="end" />
</div>
<div class="table-wrap desk">
<table>
<table v-table-cols="'invoices'">
<thead>
<tr>
<th class="check">
+3 -1
View File
@@ -19,6 +19,7 @@ import {
UiPageHeader,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
import {
@@ -1228,6 +1229,7 @@ onBeforeUnmount(() => {
</li>
</ul>
</div>
<TableColumnsMenu table-key="leads" align="end" />
<div class="view-toggle" title="نمای لیست / کاریز">
<button type="button" class="view-btn on" title="لیست">
<UiIcon name="list" :size="16" />
@@ -1408,7 +1410,7 @@ onBeforeUnmount(() => {
</div>
<div class="table-wrap">
<table class="data-table">
<table v-table-cols="'leads'" class="data-table">
<thead>
<tr>
<th class="check-col">
+5 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -743,8 +744,11 @@ onBeforeUnmount(() => {
</div>
</div>
<div class="tc-row desk">
<TableColumnsMenu table-key="loyalty.members" align="end" />
</div>
<div class="table-wrap desk">
<table>
<table v-table-cols="'loyalty.members'">
<thead>
<tr>
<th class="check">
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import axios from 'axios';
import { api } from '@/lib/api';
import UiIcon from '@/components/UiIcon.vue';
@@ -1069,7 +1070,10 @@ onUnmounted(() => {
</div>
<div v-show="!isMobile" class="list-wrap">
<table v-if="items.length" class="desk-table">
<div v-if="items.length" class="tc-row">
<TableColumnsMenu table-key="message-automations" align="end" />
</div>
<table v-if="items.length" v-table-cols="'message-automations'" class="desk-table">
<thead>
<tr>
<th>عنوان</th>
+5 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -349,8 +350,11 @@ onMounted(load);
<template v-else>
<!-- Desktop table -->
<div class="tc-row desk">
<TableColumnsMenu table-key="products" align="end" />
</div>
<div class="table-wrap desk">
<table class="crm-table">
<table v-table-cols="'products'" class="crm-table">
<thead>
<tr>
<th class="check">
+5 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
@@ -300,7 +301,10 @@ onMounted(() => {
description="پس از وقوع رویدادها اینجا دیده می‌شوند."
/>
<div v-else class="table-wrap">
<table>
<div class="tc-row">
<TableColumnsMenu table-key="public-api.deliveries" align="end" />
</div>
<table v-table-cols="'public-api.deliveries'">
<thead>
<tr>
<th>زمان</th>
+5 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -608,8 +609,11 @@ onMounted(() => {
<template v-else>
<!-- Desktop table -->
<div class="tc-row desk-only">
<TableColumnsMenu table-key="sales-forecast" align="end" />
</div>
<div class="table-wrap desk-only">
<table>
<table v-table-cols="'sales-forecast'">
<thead>
<tr>
<th>#</th>
+5 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -125,7 +126,10 @@ onMounted(() => {
description="وقتی مخاطب روی لینک داخل پیامک بزند، اینجا دیده می‌شود."
/>
<div v-else class="table-wrap">
<table class="table">
<div class="tc-row">
<TableColumnsMenu table-key="sms.campaign.clicks" align="end" />
</div>
<table v-table-cols="'sms.campaign.clicks'" class="table">
<thead>
<tr>
<th>مخاطب</th>
+9 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -813,8 +814,11 @@ onMounted(load);
<div v-if="syncResults.length" class="sync-results-box">
<h4 class="sync-results-title">نتایج جستجو ({{ syncResults.length }})</h4>
<div class="tc-row">
<TableColumnsMenu table-key="tahesab.customers" align="end" />
</div>
<div class="crm-table-container">
<table class="crm-table">
<table v-table-cols="'tahesab.customers'" class="crm-table">
<thead>
<tr>
<th>کد مشتری</th>
@@ -924,8 +928,11 @@ onMounted(load);
<h4 class="sync-results-title">
نتایج ({{ productSyncResults.length }}) — دسته: {{ productCategoryLabel }}
</h4>
<div class="tc-row">
<TableColumnsMenu table-key="tahesab.products" align="end" />
</div>
<div class="crm-table-container">
<table class="crm-table">
<table v-table-cols="'tahesab.products'" class="crm-table">
<thead>
<tr>
<th>نام</th>
+5 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -897,8 +898,11 @@ onUnmounted(() => {
</article>
</div>
<div v-if="tab === 'users'" class="tc-row desktop-table">
<TableColumnsMenu table-key="team.users" align="end" />
</div>
<div v-if="tab === 'users'" class="table-card desktop-table">
<table>
<table v-table-cols="'team.users'">
<thead>
<tr>
<th>نام و نام خانوادگی</th>
+7 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -206,8 +207,13 @@ onMounted(load);
<strong>{{ toFaDigits(items.length) }} تیکت</strong>
</div>
<div class="desktop-only">
<div class="tc-row">
<TableColumnsMenu table-key="tickets" align="end" />
</div>
</div>
<div class="table-wrap desktop-only">
<table class="data-table">
<table v-table-cols="'tickets'" class="data-table">
<thead>
<tr>
<th>موضوع</th>
+5 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -230,7 +231,10 @@ onMounted(() => {
description="پس از ارسال رویداد از سانترال اینجا دیده می‌شوند."
/>
<div v-else class="table-wrap">
<table>
<div class="tc-row">
<TableColumnsMenu table-key="voip.calls" align="end" />
</div>
<table v-table-cols="'voip.calls'">
<thead>
<tr>
<th>جهت</th>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
@@ -241,7 +242,10 @@ onMounted(() => {
description="همگام‌سازی را بزنید یا وب‌هوک را وصل کنید."
/>
<div v-else class="table-wrap">
<table>
<div class="tc-row">
<TableColumnsMenu table-key="woocommerce.orders" align="end" />
</div>
<table v-table-cols="'woocommerce.orders'">
<thead>
<tr>
<th>سفارش ووکامرس</th>