Add per-user saved column visibility for tables
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 $$;
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user