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:
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user