feat: separate prior records behind an owner switch and count last-period questions

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-15 10:48:30 +03:30
co-authored by Cursor
parent 3a8a684aa4
commit 6bc5e1bec7
29 changed files with 775 additions and 77 deletions
@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import type {
OrgUserDto,
TeamDto,
@@ -23,6 +24,15 @@ const props = defineProps<{
canEdit: boolean;
}>();
const auth = useAuthStore();
const showPriorArchive = computed(
() =>
auth.user?.role === 'owner' &&
Boolean(auth.user.enabledModules?.includes('prior_data_archive')),
);
const priorUsers = computed(() => props.users.filter((u) => u.isActive !== false));
const priorSavingId = ref('');
const emit = defineEmits<{
refreshUsers: [];
}>();
@@ -45,6 +55,25 @@ const editableUsers = computed(() =>
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'),
);
async function setPriorArchive(userId: string, enabled: boolean) {
priorSavingId.value = userId;
error.value = '';
ok.value = '';
try {
await api.patch(`/users/${userId}/prior-archive`, { enabled });
ok.value = enabled
? 'بایگانی اطلاعات قبل برای این کاربر روشن شد.'
: 'بایگانی اطلاعات قبل برای این کاربر خاموش شد.';
emit('refreshUsers');
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره ناموفق بود.')
: 'ذخیره ناموفق بود.';
} finally {
priorSavingId.value = '';
}
}
async function load() {
loading.value = true;
error.value = '';
@@ -296,6 +325,23 @@ onMounted(() => {
</div>
</section>
<section v-if="showPriorArchive" class="card">
<h3>بایگانی اطلاعات قبل</h3>
<p class="hint">
فقط کاربرانی که تیک می‌خورند سوئیچ کار جاری و بایگانی را می‌بینند. بقیهٔ محیط مثل قبل همهٔ
رکوردها را مطابق سطح دسترسی خودشان می‌بینند.
</p>
<label v-for="u in priorUsers" :key="u.id" class="vis-row">
<input
type="checkbox"
:checked="Boolean(u.priorArchiveEnabled)"
:disabled="priorSavingId === u.id"
@change="setPriorArchive(u.id, ($event.target as HTMLInputElement).checked)"
/>
<span>{{ u.fullName }}</span>
</label>
</section>
<section class="card">
<h3>سطح دسترسی به پرونده‌ها</h3>
<p class="hint">
@@ -71,6 +71,9 @@ function onVoiceTranscript(text: string) {
}
function looksLikeAction(text: string) {
if (/[؟?]/.test(text) || /چند|تعداد|چقدر|چنتا|چیست|چیه|کدام|کدوم|چرا|چطور|چگونه/.test(text)) {
return false;
}
return /بساز|بسازید|ثبت کن|ثبت کنید|اضافه کن|ایجاد کن|یک مشتری|مشتری جدید|یک فعالیت|فعالیت جدید|یک لید|لید جدید/.test(
text,
);
+66
View File
@@ -13,6 +13,7 @@ import GamificationToaster from '@/components/gamification/GamificationToaster.v
import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs';
import type {
ActivitySummaryDto,
AuthUserDto,
NotificationDto,
NotificationsListDto,
} from '@mento/shared';
@@ -40,6 +41,20 @@ const isMobileNav = ref(false);
const globalSearch = ref('');
const activityBadge = ref(0);
const routineBadge = ref(0);
const priorSaving = ref(false);
const priorLens = computed(() => auth.user?.priorArchive?.lens ?? 'current');
async function setPriorLens(lens: 'current' | 'archive') {
if (!auth.user?.priorArchive || priorLens.value === lens || priorSaving.value) return;
priorSaving.value = true;
try {
const { data } = await api.patch<AuthUserDto>('/auth/me/prior-archive', { lens });
auth.setUser(data);
window.location.reload();
} finally {
priorSaving.value = false;
}
}
function pathIn(prefixes: string[]) {
return prefixes.some(
@@ -665,6 +680,24 @@ onUnmounted(() => {
</p>
<div class="sidebar-foot">
<div v-if="auth.user?.priorArchive" class="prior-switch" role="group" aria-label="بایگانی اطلاعات قبل">
<button
type="button"
:class="{ on: priorLens === 'current' }"
:disabled="priorSaving"
@click="setPriorLens('current')"
>
کار جاری
</button>
<button
type="button"
:class="{ on: priorLens === 'archive' }"
:disabled="priorSaving"
@click="setPriorLens('archive')"
>
بایگانی اطلاعات قبل
</button>
</div>
<div class="foot-actions">
<RouterLink
class="profile-card profile-foot"
@@ -1312,6 +1345,35 @@ onUnmounted(() => {
gap: 0.55rem;
}
.prior-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.3rem;
}
.prior-switch button {
border: 1px solid #e2e8f0;
background: #fff;
color: #475569;
border-radius: 8px;
padding: 0.35rem 0.3rem;
font: inherit;
font-size: 0.68rem;
font-weight: 700;
cursor: pointer;
}
.prior-switch button.on {
background: #f0fdfa;
border-color: #99f6e4;
color: #0f766e;
}
.prior-switch button:disabled {
opacity: 0.6;
cursor: wait;
}
.foot-meta {
display: grid;
gap: 0.15rem;
@@ -1769,6 +1831,10 @@ onUnmounted(() => {
flex-shrink: 0;
}
.sidebar.collapsed .prior-switch {
display: none;
}
.sidebar.collapsed .logout-link {
display: grid;
place-items: center;
+74 -4
View File
@@ -2,6 +2,7 @@
import { computed, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { formatCompactMoney, formatMoney } from '@/lib/money';
import {
calendarDaysBetween,
@@ -60,6 +61,7 @@ type TimelineRow = {
tone: TimelineTone;
href: string;
at: number;
prior?: boolean;
};
type AttachmentRow = {
@@ -180,6 +182,27 @@ const previousSourceLabel = computed(() => {
return SOURCE_LABELS[src] || src;
});
const auth = useAuthStore();
const priorArchiveOn = computed(() => Boolean(auth.user?.priorArchive));
function isPriorActivity(row: ActivityDto) {
if (row.importSource !== 'didar') return false;
if (row.status === 'open' && row.dueAt && new Date(row.dueAt).getTime() > Date.now()) return false;
return true;
}
function isPriorDeal(row: DealDto) {
if (row.importSource !== 'didar') return false;
if (
row.status === 'open' &&
row.expectedCloseAt &&
new Date(row.expectedCloseAt).getTime() > Date.now()
) {
return false;
}
return true;
}
const lastCallAt = computed(() => {
const calls = activities.value
.filter((a) => a.type === 'call')
@@ -210,6 +233,12 @@ const nextActivity = computed(() => {
});
const openDeals = computed(() => deals.value.filter((d) => d.status === 'open'));
const currentDeals = computed(() =>
priorArchiveOn.value ? deals.value.filter((d) => !isPriorDeal(d)) : deals.value,
);
const priorDeals = computed(() =>
priorArchiveOn.value ? deals.value.filter((d) => isPriorDeal(d)) : [],
);
const lastQuote = computed(
() =>
@@ -308,6 +337,7 @@ const timeline = computed<TimelineRow[]>(() => {
tone: activityTone(a),
href: `/activities/${a.id}`,
at,
prior: isPriorActivity(a),
});
}
for (const n of notes.value) {
@@ -340,7 +370,17 @@ const visibleTimeline = computed(() => {
historyFilter.value === 'all'
? timeline.value
: timeline.value.filter((row) => row.tone === historyFilter.value);
return rows.slice(0, 8);
const current = priorArchiveOn.value ? rows.filter((row) => !row.prior) : rows;
return current.slice(0, 8);
});
const priorTimeline = computed(() => {
if (!priorArchiveOn.value) return [];
const rows =
historyFilter.value === 'all'
? timeline.value
: timeline.value.filter((row) => row.tone === historyFilter.value);
return rows.filter((row) => row.prior).slice(0, 8);
});
function fileKind(name: string, mime: string): AttachmentRow['kind'] {
@@ -442,7 +482,7 @@ async function load() {
api.get<PaginatedResponse<MemoryItemDto>>('/memory', {
params: { personId: id.value, limit: 50 },
}),
api.get<PipelineBoardDto>('/pipelines/board'),
api.get<PipelineBoardDto>('/pipelines/board', { params: { prior: 'all' } }),
]);
person.value = personRes.data;
activities.value = actRes.data.items;
@@ -832,7 +872,24 @@ watch(() => route.query.tab, readTab, { immediate: true });
</RouterLink>
</li>
</ol>
<p v-else class="empty">موردی با این فیلتر نیست.</p>
<p v-else-if="!priorTimeline.length" class="empty">موردی با این فیلتر نیست.</p>
<template v-if="priorTimeline.length">
<h3 class="prior-head">بایگانی اطلاعات قبل</h3>
<ol class="tl">
<li v-for="row in priorTimeline" :key="row.key">
<RouterLink :to="row.href">
<span class="dot" :class="row.tone">
<UiIcon :name="timelineIcon(row.tone)" :size="14" />
</span>
<div>
<time>{{ row.when }}</time>
<strong>{{ row.title }}</strong>
<p v-if="row.body">{{ row.body }}</p>
</div>
</RouterLink>
</li>
</ol>
</template>
<RouterLink class="see-all" :to="{ name: 'activities', query: { filter: 'all' } }">
مشاهده همه فعالیت‌ها
</RouterLink>
@@ -896,7 +953,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
/>
<div v-if="!dealsEmpty">
<RouterLink
v-for="deal in deals"
v-for="deal in currentDeals"
:key="deal.id"
class="row-card"
:to="{ name: 'deal-detail', params: { id: deal.id } }"
@@ -905,6 +962,19 @@ watch(() => route.query.tab, readTab, { immediate: true });
<span>{{ deal.amount != null ? formatCompactMoney(deal.amount, deal.currency) : 'بدون مبلغ' }}</span>
<em>{{ dealStatusLabel[deal.status] ?? deal.status }}</em>
</RouterLink>
<div v-if="priorDeals.length" class="subblock">
<h3>بایگانی اطلاعات قبل</h3>
<RouterLink
v-for="deal in priorDeals"
:key="deal.id"
class="row-card"
:to="{ name: 'deal-detail', params: { id: deal.id } }"
>
<strong>{{ deal.title }}</strong>
<span>{{ deal.amount != null ? formatCompactMoney(deal.amount, deal.currency) : 'بدون مبلغ' }}</span>
<em>{{ dealStatusLabel[deal.status] ?? deal.status }}</em>
</RouterLink>
</div>
<div v-if="invoices.length" class="subblock">
<h3>فاکتورها</h3>
<RouterLink
@@ -584,6 +584,19 @@ onMounted(() => {
حرفه‌ای (چندشاخه، شرط روی پاسخ فرم، تقسیم نوبتی)
</label>
</div>
<div class="mod-group">
<div class="mod-group-head">
<span>بایگانی اطلاعات قبل</span>
</div>
<label class="mod-item">
<input
type="checkbox"
:checked="moduleDraft.includes('prior_data_archive')"
@change="toggleModule('prior_data_archive')"
/>
بایگانی اطلاعات قبل
</label>
</div>
</div>
<div class="chat-settings">