feat: keep deletions restorable and confirm a pipeline's deals before it is removed
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -36,7 +36,8 @@ export type NavIconName =
|
||||
| 'plus'
|
||||
| 'user-plus'
|
||||
| 'mail'
|
||||
| 'phone';
|
||||
| 'phone'
|
||||
| 'trash';
|
||||
|
||||
export type NavHubTint = 'mint' | 'violet' | 'peach' | 'sky' | 'rose' | 'amber';
|
||||
|
||||
@@ -437,6 +438,14 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
module: 'audit_logs',
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
to: '/settings/deletions',
|
||||
title: 'لاگ حذفیات',
|
||||
desc: 'چه کسی چه چیزی را حذف کرده، بازگردانی یا حذف کامل',
|
||||
icon: 'trash',
|
||||
tint: 'rose',
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
to: '/settings/public-api',
|
||||
title: 'API و وبهوک',
|
||||
|
||||
@@ -361,6 +361,12 @@ export const router = createRouter({
|
||||
component: () => import('@/views/AuditLogsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/deletions',
|
||||
name: 'deletion-archives',
|
||||
component: () => import('@/views/DeletionArchivesView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/public-api',
|
||||
name: 'public-api',
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import type { DeletionArchiveListResponse, DeletionRetention } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
|
||||
import { UiButton, UiCard, UiEmptyState, UiModal, UiPageHeader } from '@/components/ui';
|
||||
|
||||
const RETENTION_OPTIONS: Array<{ value: DeletionRetention; label: string }> = [
|
||||
{ value: 'month', label: 'یک ماه' },
|
||||
{ value: 'two_months', label: 'دو ماه' },
|
||||
{ value: 'three_months', label: 'سه ماه' },
|
||||
{ value: 'six_months', label: 'شش ماه' },
|
||||
{ value: 'year', label: 'یک سال' },
|
||||
{ value: 'never', label: 'هرگز' },
|
||||
];
|
||||
|
||||
const loading = ref(false);
|
||||
const savingRetention = ref(false);
|
||||
const busyId = ref('');
|
||||
const error = ref('');
|
||||
const okMsg = ref('');
|
||||
const q = ref('');
|
||||
const page = ref(1);
|
||||
const retention = ref<DeletionRetention>('never');
|
||||
const data = ref<DeletionArchiveListResponse | null>(null);
|
||||
const purgeId = ref<string | null>(null);
|
||||
|
||||
const totalPages = computed(() => {
|
||||
if (!data.value) return 1;
|
||||
return Math.max(1, Math.ceil(data.value.total / data.value.limit));
|
||||
});
|
||||
|
||||
const purgeItem = computed(() => data.value?.items.find((item) => item.id === purgeId.value) ?? null);
|
||||
|
||||
function apiError(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
if (!e.response || e.response.status === 404) return fallback;
|
||||
if (Array.isArray(msg)) return msg.join('، ');
|
||||
if (msg) return msg;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function load(resetPage = false) {
|
||||
if (resetPage) page.value = 1;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data: res } = await api.get<DeletionArchiveListResponse>('/deletions', {
|
||||
params: { q: q.value.trim() || undefined, page: page.value, limit: 30 },
|
||||
});
|
||||
data.value = res;
|
||||
retention.value = res.retention;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری لاگ حذف ناموفق بود.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRetention() {
|
||||
savingRetention.value = true;
|
||||
error.value = '';
|
||||
okMsg.value = '';
|
||||
try {
|
||||
await api.patch('/deletions/settings', { retention: retention.value });
|
||||
okMsg.value = 'مدت نگهداری ذخیره شد.';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ذخیره مدت نگهداری ناموفق بود.');
|
||||
} finally {
|
||||
savingRetention.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(id: string) {
|
||||
busyId.value = id;
|
||||
error.value = '';
|
||||
okMsg.value = '';
|
||||
try {
|
||||
await api.post(`/deletions/${id}/restore`);
|
||||
okMsg.value = 'مورد برگردانده شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بازگردانی ناموفق بود.');
|
||||
} finally {
|
||||
busyId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPurge() {
|
||||
const id = purgeId.value;
|
||||
if (!id) return;
|
||||
busyId.value = id;
|
||||
error.value = '';
|
||||
okMsg.value = '';
|
||||
try {
|
||||
await api.delete(`/deletions/${id}`);
|
||||
purgeId.value = null;
|
||||
okMsg.value = 'این مورد برای همیشه پاک شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'حذف کامل ناموفق بود.');
|
||||
} finally {
|
||||
busyId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function go(delta: number) {
|
||||
page.value = Math.min(totalPages.value, Math.max(1, page.value + delta));
|
||||
void load();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader
|
||||
title="لاگ حذفیات"
|
||||
lead="هر حذف اینجا میماند. میتوانید برگردانید یا برای همیشه پاک کنید."
|
||||
/>
|
||||
|
||||
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<UiCard>
|
||||
<div class="retention">
|
||||
<label for="deletion-retention">حذف خودکار از این فهرست</label>
|
||||
<select id="deletion-retention" v-model="retention">
|
||||
<option v-for="option in RETENTION_OPTIONS" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<UiButton size="sm" :loading="savingRetention" @click="saveRetention">ذخیره</UiButton>
|
||||
</div>
|
||||
<p class="hint">
|
||||
تا پایان این مدت میتوان مورد را برگرداند. «هرگز» یعنی فقط با حذف کامل از بین میرود.
|
||||
</p>
|
||||
</UiCard>
|
||||
|
||||
<form class="search" @submit.prevent="load(true)">
|
||||
<input v-model="q" type="search" placeholder="جستجو در عنوان یا نوع" />
|
||||
<UiButton type="submit" variant="secondary" size="sm">جستجو</UiButton>
|
||||
</form>
|
||||
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<UiEmptyState
|
||||
v-else-if="!error && !data?.items.length"
|
||||
title="حذفی ثبت نشده."
|
||||
description="از این به بعد حذفها با نام کاربر و زمان اینجا دیده میشوند."
|
||||
/>
|
||||
|
||||
<div v-else-if="data?.items.length" class="list">
|
||||
<article v-for="item in data.items" :key="item.id" class="row">
|
||||
<div class="info">
|
||||
<strong>{{ item.label }}</strong>
|
||||
<span>{{ item.modelLabel }}</span>
|
||||
<span>
|
||||
{{ item.deletedByName || 'سیستم' }}
|
||||
·
|
||||
{{ formatJalaliDateTime(new Date(item.deletedAt)) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ops">
|
||||
<UiButton size="sm" variant="secondary" :disabled="busyId === item.id" @click="restore(item.id)">
|
||||
بازگردانی
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="danger" :disabled="busyId === item.id" @click="purgeId = item.id">
|
||||
حذف کامل
|
||||
</UiButton>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="data && data.total > data.limit" class="pager">
|
||||
<UiButton size="sm" variant="ghost" :disabled="page <= 1" @click="go(-1)">قبلی</UiButton>
|
||||
<span>{{ toFaDigits(page) }} از {{ toFaDigits(totalPages) }}</span>
|
||||
<UiButton size="sm" variant="ghost" :disabled="page >= totalPages" @click="go(1)">بعدی</UiButton>
|
||||
</div>
|
||||
|
||||
<UiModal :open="!!purgeItem" title="حذف کامل" @close="purgeId = null">
|
||||
<p v-if="purgeItem" class="confirm">
|
||||
«{{ purgeItem.label }}» برای همیشه پاک شود؟ بعد از این دیگر قابل بازگشت نیست.
|
||||
</p>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" :disabled="!!busyId" @click="purgeId = null">انصراف</UiButton>
|
||||
<UiButton variant="danger" :loading="!!busyId" @click="confirmPurge">حذف کامل</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.retention {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.retention label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.retention select,
|
||||
.search input {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
padding: 0.45rem 0.7rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hint,
|
||||
.muted {
|
||||
margin: 0.55rem 0 0;
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search input {
|
||||
flex: 1 1 12rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e7eef2;
|
||||
border-radius: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info strong,
|
||||
.info span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info span {
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.ops {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.confirm {
|
||||
margin: 0;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.ok {
|
||||
margin: 0;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,9 @@ const pipelines = ref<PipelineListItem[]>([]);
|
||||
const name = ref('');
|
||||
const mergeOpen = ref(false);
|
||||
const mergeSourceId = ref<string | null>(null);
|
||||
const deleteTarget = ref<PipelineListItem | null>(null);
|
||||
const deleteCountReady = ref(false);
|
||||
const deleting = ref(false);
|
||||
|
||||
const canMerge = computed(() => pipelines.value.length >= 2);
|
||||
|
||||
@@ -67,6 +70,61 @@ async function createPipeline() {
|
||||
}
|
||||
}
|
||||
|
||||
function deletePrompt(pipeline: PipelineListItem) {
|
||||
if (!deleteCountReady.value) return 'در حال شمارش معاملات این مسیر…';
|
||||
const count = pipeline.dealCount ?? 0;
|
||||
const name = pipeline.name;
|
||||
if (count > 0) {
|
||||
return `مسیر «${name}» شامل ${toFaDigits(count)} معامله است. با حذف، این معاملات هم پاک میشوند. از حذف مطمئن هستید؟`;
|
||||
}
|
||||
return `مسیر «${name}» معاملهای ندارد. از حذف آن مطمئن هستید؟`;
|
||||
}
|
||||
|
||||
async function openDelete(pipeline: PipelineListItem) {
|
||||
deleteTarget.value = pipeline;
|
||||
deleteCountReady.value = pipeline.dealCount != null;
|
||||
if (deleteCountReady.value) return;
|
||||
try {
|
||||
const { data } = await api.get<{ dealCount: number }>(
|
||||
`/pipelines/${pipeline.id}/merge-preview`,
|
||||
);
|
||||
if (deleteTarget.value?.id !== pipeline.id) return;
|
||||
deleteTarget.value = { ...pipeline, dealCount: data.dealCount };
|
||||
} catch (e) {
|
||||
if (deleteTarget.value?.id === pipeline.id) {
|
||||
error.value = apiError(e, 'شمارش معاملات این مسیر ناموفق بود.');
|
||||
deleteTarget.value = null;
|
||||
}
|
||||
return;
|
||||
} finally {
|
||||
if (deleteTarget.value?.id === pipeline.id) deleteCountReady.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDelete() {
|
||||
if (deleting.value) return;
|
||||
deleteTarget.value = null;
|
||||
deleteCountReady.value = false;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const pipeline = deleteTarget.value;
|
||||
if (!pipeline || deleting.value) return;
|
||||
deleting.value = true;
|
||||
error.value = '';
|
||||
okMsg.value = '';
|
||||
try {
|
||||
await api.delete(`/pipelines/${pipeline.id}`);
|
||||
okMsg.value = 'کاریز حذف شد.';
|
||||
deleteTarget.value = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'حذف کاریز ناموفق بود.');
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openMerge(pipeline: PipelineListItem) {
|
||||
if (!canMerge.value) return;
|
||||
mergeSourceId.value = pipeline.id;
|
||||
@@ -142,6 +200,9 @@ onMounted(load);
|
||||
>
|
||||
ادغام
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="danger" @click="openDelete(pipeline)">
|
||||
حذف
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</UiCard>
|
||||
@@ -153,6 +214,25 @@ onMounted(load);
|
||||
description="اولین مسیر فروش را اضافه کنید."
|
||||
/>
|
||||
|
||||
<UiModal
|
||||
:open="!!deleteTarget"
|
||||
title="حذف کاریز"
|
||||
@close="closeDelete"
|
||||
>
|
||||
<p v-if="deleteTarget" class="confirm">{{ deletePrompt(deleteTarget) }}</p>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" :disabled="deleting" @click="closeDelete">انصراف</UiButton>
|
||||
<UiButton
|
||||
variant="danger"
|
||||
:loading="deleting"
|
||||
:disabled="!deleteCountReady"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
حذف کاریز
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
|
||||
<UiModal :open="mergeOpen" title="ادغام کاریز" @close="closeMerge">
|
||||
<PipelineMergeWizard
|
||||
:open="mergeOpen"
|
||||
@@ -242,6 +322,12 @@ onMounted(load);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.confirm {
|
||||
margin: 0;
|
||||
line-height: 1.8;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
margin: 0;
|
||||
|
||||
Reference in New Issue
Block a user