feat(web): continue Ant settings migration and tighten page chrome
Migrate remaining settings screens to Ant Design Vue, densify teams/roles UI, drop page-header subtitles project-wide, and polish notes cards plus contacts row actions. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import { Select as ASelect } from 'ant-design-vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Card as ACard,
|
||||
Checkbox as ACheckbox,
|
||||
Flex as AFlex,
|
||||
FormItem as AFormItem,
|
||||
Radio as ARadio,
|
||||
RadioGroup as ARadioGroup,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type {
|
||||
DataAccessOwnerKind,
|
||||
DataAccessRule,
|
||||
@@ -15,9 +26,12 @@ import {
|
||||
PERSON_MOODS,
|
||||
sectionHasDataAccessLimits,
|
||||
} from '@mento/shared';
|
||||
|
||||
export type IdOption = { id: string; name: string };
|
||||
type Choice = { value: string; label: string };
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: SectionDataAccess;
|
||||
@@ -429,53 +443,59 @@ const filterSummary = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dae" :class="{ compact }">
|
||||
<div class="dae-mode">
|
||||
<label>
|
||||
<input v-model="mode" type="radio" value="all" :disabled="disabled" />
|
||||
همهٔ {{ sectionLabel }}
|
||||
</label>
|
||||
<label>
|
||||
<input v-model="mode" type="radio" value="rules" :disabled="disabled" />
|
||||
محدود با فیلتر
|
||||
</label>
|
||||
</div>
|
||||
<a-flex vertical :gap="compact ? 8 : 12">
|
||||
<a-radio-group v-model:value="mode" :disabled="disabled">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-radio value="all">همهٔ {{ sectionLabel }}</a-radio>
|
||||
<a-radio value="rules">محدود با فیلتر</a-radio>
|
||||
</a-flex>
|
||||
</a-radio-group>
|
||||
|
||||
<template v-if="mode === 'rules'">
|
||||
<p class="hint">
|
||||
<a-typography-text type="secondary">
|
||||
فقط رکوردهایی دیده میشوند که با
|
||||
<strong>همهٔ فیلترهای پرشده</strong>
|
||||
جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت.
|
||||
</p>
|
||||
<p v-if="hadMultipleRules" class="hint warn">
|
||||
تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت میشود.
|
||||
</p>
|
||||
<p class="summary">{{ filterSummary }}</p>
|
||||
</a-typography-text>
|
||||
<a-alert
|
||||
v-if="hadMultipleRules"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت میشود."
|
||||
/>
|
||||
<a-typography-text strong>{{ filterSummary }}</a-typography-text>
|
||||
|
||||
<fieldset :disabled="disabled">
|
||||
<legend>مالک / مسئول</legend>
|
||||
<p class="muted">خالی = بدون محدودیت مالک</p>
|
||||
<label class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
<a-card size="small" title="مالک / مسئول" :bordered="true">
|
||||
<a-flex vertical :gap="8">
|
||||
<a-typography-text type="secondary">خالی = بدون محدودیت مالک</a-typography-text>
|
||||
<a-space wrap :size="8">
|
||||
<a-checkbox
|
||||
:checked="filter.owners?.includes('self')"
|
||||
:disabled="disabled"
|
||||
@change="toggleOwner('self')"
|
||||
/>
|
||||
>
|
||||
{{ ownerSelfLabel }}
|
||||
</label>
|
||||
<label class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
</a-checkbox>
|
||||
<a-checkbox
|
||||
:checked="filter.owners?.includes('team')"
|
||||
:disabled="disabled"
|
||||
@change="toggleOwner('team')"
|
||||
/>
|
||||
>
|
||||
تیمهای عضو
|
||||
</label>
|
||||
</fieldset>
|
||||
</a-checkbox>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
<label v-for="picker in pickers" :key="picker.legend" class="pick">
|
||||
<span>{{ picker.legend }}</span>
|
||||
<p v-if="picker.loading" class="muted">در حال بارگذاری منبعها…</p>
|
||||
<a-form-item
|
||||
v-for="picker in pickers"
|
||||
:key="picker.legend"
|
||||
:label="picker.legend"
|
||||
:style="{ marginBottom: 0 }"
|
||||
>
|
||||
<a-typography-text v-if="picker.loading" type="secondary">
|
||||
در حال بارگذاری منبعها…
|
||||
</a-typography-text>
|
||||
<a-select
|
||||
v-else
|
||||
:value="pickerValue(picker)"
|
||||
@@ -490,70 +510,14 @@ const filterSummary = computed(() => {
|
||||
style="width: 100%"
|
||||
@update:value="setList(picker.key, $event)"
|
||||
/>
|
||||
<p v-if="picker.hint" class="muted">{{ picker.hint }}</p>
|
||||
</label>
|
||||
<a-typography-text v-if="picker.hint" type="secondary">
|
||||
{{ picker.hint }}
|
||||
</a-typography-text>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<p v-else-if="!hasLimits" class="hint">
|
||||
<a-typography-text v-else-if="!hasLimits" type="secondary">
|
||||
بدون محدودیت — همهٔ {{ sectionLabel }} قابل مشاهدهاند.
|
||||
</p>
|
||||
</div>
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dae {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.dae-mode {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.dae-mode label,
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.88rem;
|
||||
margin-inline-end: 0.55rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.hint,
|
||||
.muted,
|
||||
.summary {
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
margin: 0;
|
||||
}
|
||||
.hint.warn {
|
||||
color: #b45309;
|
||||
}
|
||||
.summary {
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
fieldset {
|
||||
border: 1px dashed #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
margin: 0;
|
||||
}
|
||||
legend {
|
||||
padding-inline: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pick {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.pick > span {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
.compact fieldset {
|
||||
padding: 0.45rem 0.55rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { Tabs as ATabs, TabPane as ATabPane, Typography } from 'ant-design-vue';
|
||||
import type {
|
||||
DataAccessPolicy,
|
||||
DataAccessSection,
|
||||
@@ -16,6 +17,8 @@ import { api } from '@/lib/api';
|
||||
import { useCustomerGroups } from '@/lib/customer-groups';
|
||||
import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: DataAccessPolicy;
|
||||
/** فقط بخشهایی که این مجوزها را دارند نشان بده؛ خالی = همه */
|
||||
@@ -177,95 +180,35 @@ async function loadOptions() {
|
||||
onMounted(() => {
|
||||
void loadOptions();
|
||||
});
|
||||
|
||||
const stagesForActive = computed(() => {
|
||||
if (active.value === 'leads') return leadStages.value;
|
||||
if (active.value === 'sales') return dealStages.value;
|
||||
if (active.value === 'activities') return activityStages.value;
|
||||
if (active.value === 'projects') return projectStages.value;
|
||||
return [];
|
||||
});
|
||||
|
||||
const boardsForActive = computed(() => {
|
||||
if (active.value === 'activities') return activityBoards.value;
|
||||
if (active.value === 'projects') return projectBoards.value;
|
||||
return [];
|
||||
});
|
||||
|
||||
const typesForActive = computed(() => {
|
||||
if (active.value === 'activities') return activityTypes.value;
|
||||
if (active.value === 'routines') return routineCategories.value;
|
||||
return [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dap">
|
||||
<p v-if="!visibleSections.length" class="muted">
|
||||
<div>
|
||||
<a-typography-text v-if="!visibleSections.length" type="secondary">
|
||||
برای تنظیم محدوده، ابتدا مجوز بخش مربوط را روشن کنید.
|
||||
</p>
|
||||
<template v-else>
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
</a-typography-text>
|
||||
<a-tabs v-else v-model:activeKey="active">
|
||||
<a-tab-pane
|
||||
v-for="s in visibleSections"
|
||||
:key="s"
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ on: active === s }"
|
||||
role="tab"
|
||||
:aria-selected="active === s"
|
||||
@click="active = s"
|
||||
:tab="DATA_ACCESS_SECTION_LABELS[s]"
|
||||
>
|
||||
{{ DATA_ACCESS_SECTION_LABELS[s] }}
|
||||
</button>
|
||||
</div>
|
||||
<DataAccessEditor
|
||||
:key="active"
|
||||
:section="active"
|
||||
:model-value="sectionValue(active)"
|
||||
:section="s"
|
||||
:model-value="sectionValue(s)"
|
||||
:groups="customerGroups"
|
||||
:stages="stagesForActive"
|
||||
:stages="s === 'leads' ? leadStages : s === 'sales' ? dealStages : s === 'activities' ? activityStages : s === 'projects' ? projectStages : []"
|
||||
:pipelines="pipelines"
|
||||
:boards="boardsForActive"
|
||||
:activity-types="typesForActive"
|
||||
:boards="s === 'activities' ? activityBoards : s === 'projects' ? projectBoards : []"
|
||||
:activity-types="s === 'activities' ? activityTypes : s === 'routines' ? routineCategories : []"
|
||||
:import-sources="importSources"
|
||||
:import-sources-ready="importSourcesReady"
|
||||
:import-sources-failed="importSourcesFailed"
|
||||
:disabled="disabled"
|
||||
:compact="compact"
|
||||
@update:model-value="setSection(active, $event)"
|
||||
@update:model-value="setSection(s, $event)"
|
||||
/>
|
||||
</template>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dap {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.tab {
|
||||
font: inherit;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.7rem;
|
||||
cursor: pointer;
|
||||
color: #475569;
|
||||
}
|
||||
.tab.on {
|
||||
border-color: #0f766e;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { OrgRoleDto, RolePermissionKey } from '@mento/shared';
|
||||
import {
|
||||
Avatar as AAvatar,
|
||||
Descriptions as ADescriptions,
|
||||
DescriptionsItem as ADescriptionsItem,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
ListItem as AListItem,
|
||||
ListItemMeta as AListItemMeta,
|
||||
Space as ASpace,
|
||||
TabPane as ATabPane,
|
||||
Tabs as ATabs,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { UserOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Text: ATypographyText, Link: ATypographyLink } = Typography;
|
||||
const emptySimple = AEmpty.PRESENTED_IMAGE_SIMPLE;
|
||||
|
||||
const props = defineProps<{
|
||||
role: OrgRoleDto;
|
||||
childRoles: OrgRoleDto[];
|
||||
permissionItems: Array<{ key: RolePermissionKey; label: string }>;
|
||||
avatarOf: (id: string) => string;
|
||||
tagColor: (color: string | null | undefined) => string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'pick-role': [id: string];
|
||||
}>();
|
||||
|
||||
const innerTab = ref('summary');
|
||||
|
||||
watch(
|
||||
() => props.role.id,
|
||||
() => {
|
||||
innerTab.value = 'summary';
|
||||
},
|
||||
);
|
||||
|
||||
const grantedPermissions = computed(() =>
|
||||
props.permissionItems.filter((item) => props.role.permissions.includes(item.key)),
|
||||
);
|
||||
|
||||
const permSummary = computed(
|
||||
() =>
|
||||
`${toFaDigits(grantedPermissions.value.length)} از ${toFaDigits(props.permissionItems.length)}`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-space direction="vertical" :size="4">
|
||||
<a-space wrap>
|
||||
<a-tag v-if="role.systemKey" :bordered="false">سیستمی</a-tag>
|
||||
<a-typography-text v-if="role.parentName" type="secondary">
|
||||
زیرمجموعهٔ
|
||||
<a-typography-link @click="emit('pick-role', role.parentId!)">
|
||||
{{ role.parentName }}
|
||||
</a-typography-link>
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
<a-typography-text v-if="role.description" type="secondary">
|
||||
{{ role.description }}
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
|
||||
<a-tabs v-model:activeKey="innerTab">
|
||||
<a-tab-pane key="summary" tab="خلاصه">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-descriptions :column="1" bordered>
|
||||
<a-descriptions-item label="کاربران">
|
||||
{{ toFaDigits(role.userCount) }} نفر
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="مجوزها">
|
||||
{{ permSummary }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="زیرنقشها">
|
||||
{{ toFaDigits(childRoles.length) }} مورد
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-flex vertical :gap="8">
|
||||
<a-typography-text strong>زیرنقشها</a-typography-text>
|
||||
<a-space v-if="childRoles.length" wrap>
|
||||
<a-tag
|
||||
v-for="child in childRoles"
|
||||
:key="child.id"
|
||||
:color="tagColor(child.color)"
|
||||
:bordered="false"
|
||||
style="cursor: pointer"
|
||||
@click="emit('pick-role', child.id)"
|
||||
>
|
||||
{{ child.name }} · {{ toFaDigits(child.userCount) }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
<a-empty v-else description="زیرنقشی تعریف نشده" :image="emptySimple" />
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="perms" :tab="`مجوزها (${permSummary})`">
|
||||
<a-flex vertical :gap="12">
|
||||
<a-typography-text type="secondary">
|
||||
فقط مجوزهای فعال نمایش داده میشود. برای تغییر، ویرایش نقش را بزنید.
|
||||
</a-typography-text>
|
||||
<a-space v-if="grantedPermissions.length" wrap>
|
||||
<a-tag
|
||||
v-for="perm in grantedPermissions"
|
||||
:key="perm.key"
|
||||
color="success"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ perm.label }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
<a-empty v-else description="هیچ مجوزی فعال نیست" :image="emptySimple" />
|
||||
</a-flex>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="members" :tab="`کاربران (${toFaDigits(role.userCount)})`">
|
||||
<a-list v-if="role.members.length" :data-source="role.members">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta :title="item.fullName">
|
||||
<template #avatar>
|
||||
<a-avatar :src="avatarOf(item.id)">
|
||||
<template #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
<a-empty v-else description="کاربری با این نقش نیست" :image="emptySimple" />
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-flex>
|
||||
</template>
|
||||
@@ -18,9 +18,36 @@ import {
|
||||
emptySectionDataAccess,
|
||||
sectionHasDataAccessLimits,
|
||||
} from '@mento/shared';
|
||||
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
|
||||
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
|
||||
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Checkbox as ACheckbox,
|
||||
Col as ACol,
|
||||
Collapse as ACollapse,
|
||||
CollapsePanel as ACollapsePanel,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
ListItem as AListItem,
|
||||
ListItemMeta as AListItemMeta,
|
||||
Radio as ARadio,
|
||||
RadioGroup as ARadioGroup,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
const props = defineProps<{
|
||||
users: OrgUserDto[];
|
||||
@@ -36,7 +63,7 @@ const showPriorArchive = computed(
|
||||
const priorUsers = computed(() => props.users.filter((u) => u.isActive !== false));
|
||||
const selectedPriorUsers = computed(() => priorUsers.value.filter((u) => u.priorArchiveEnabled));
|
||||
const priorSearch = ref('');
|
||||
const priorPick = ref('');
|
||||
const priorPick = ref<string | undefined>(undefined);
|
||||
const priorSavingId = ref('');
|
||||
const cutoffLocal = ref('');
|
||||
const cutoffSaving = ref(false);
|
||||
@@ -47,10 +74,16 @@ const availablePriorUsers = computed(() => {
|
||||
return priorUsers.value.filter((u) => {
|
||||
if (u.priorArchiveEnabled) return false;
|
||||
if (!q) return true;
|
||||
return [u.fullName, u.email, u.mobile].filter(Boolean).some((v) => String(v).toLowerCase().includes(q));
|
||||
return [u.fullName, u.email, u.mobile]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(q));
|
||||
});
|
||||
});
|
||||
|
||||
const availablePriorOptions = computed(() =>
|
||||
availablePriorUsers.value.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
refreshUsers: [];
|
||||
}>();
|
||||
@@ -73,9 +106,14 @@ const editableUsers = computed(() =>
|
||||
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'),
|
||||
);
|
||||
|
||||
const memberRoleOptions = [
|
||||
{ value: 'member', label: 'عضو' },
|
||||
{ value: 'manager', label: 'سرپرست' },
|
||||
];
|
||||
|
||||
async function setPriorArchive(userId: string, enabled: boolean) {
|
||||
priorSavingId.value = userId;
|
||||
priorPick.value = '';
|
||||
priorPick.value = undefined;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
@@ -113,7 +151,10 @@ async function loadPriorArchiveCutoff() {
|
||||
applyCutoffFromOrg(data);
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'بارگذاری تاریخ بایگانی ناموفق بود.')
|
||||
? String(
|
||||
(e.response?.data as { message?: string })?.message ??
|
||||
'بارگذاری تاریخ بایگانی ناموفق بود.',
|
||||
)
|
||||
: 'بارگذاری تاریخ بایگانی ناموفق بود.';
|
||||
} finally {
|
||||
cutoffLoading.value = false;
|
||||
@@ -135,7 +176,10 @@ async function onCutoffChange(value: string) {
|
||||
await auth.hydrate();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره تاریخ بایگانی ناموفق بود.')
|
||||
? String(
|
||||
(e.response?.data as { message?: string })?.message ??
|
||||
'ذخیره تاریخ بایگانی ناموفق بود.',
|
||||
)
|
||||
: 'ذخیره تاریخ بایگانی ناموفق بود.';
|
||||
await loadPriorArchiveCutoff();
|
||||
} finally {
|
||||
@@ -143,9 +187,10 @@ async function onCutoffChange(value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function pickPriorUser(userId: string) {
|
||||
if (!userId) return;
|
||||
void setPriorArchive(userId, true);
|
||||
function pickPriorUser(userId: unknown) {
|
||||
const id = userId == null ? '' : String(userId);
|
||||
if (!id) return;
|
||||
void setPriorArchive(id, true);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -242,9 +287,12 @@ function toggleMember(userId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function setRole(userId: string, role: TeamMemberRole) {
|
||||
function setRole(userId: string, role: unknown) {
|
||||
if (!memberDraft.value[userId]) return;
|
||||
memberDraft.value = { ...memberDraft.value, [userId]: role };
|
||||
memberDraft.value = {
|
||||
...memberDraft.value,
|
||||
[userId]: String(role) as TeamMemberRole,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveMembers() {
|
||||
@@ -358,47 +406,57 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="teams-panel">
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<a-flex vertical :gap="12">
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
|
||||
|
||||
<section v-if="canEdit" class="card">
|
||||
<h3>تیم جدید</h3>
|
||||
<div class="row">
|
||||
<UiField label="نام تیم">
|
||||
<UiInput v-model="newName" placeholder="مثلاً فروش تهران" />
|
||||
</UiField>
|
||||
<UiField label="توضیح">
|
||||
<UiInput v-model="newDesc" placeholder="اختیاری" />
|
||||
</UiField>
|
||||
<UiButton :loading="saving" @click="createTeam">افزودن</UiButton>
|
||||
</div>
|
||||
</section>
|
||||
<a-card :bordered="false">
|
||||
<a-form v-if="canEdit" layout="vertical" @finish="createTeam">
|
||||
<a-divider orientation="right">تیم جدید</a-divider>
|
||||
<a-row :gutter="[16, 0]" align="bottom">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نام تیم">
|
||||
<a-input v-model:value="newName" placeholder="مثلاً فروش تهران" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="10">
|
||||
<a-form-item label="توضیح">
|
||||
<a-input v-model:value="newDesc" placeholder="اختیاری" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="6">
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" html-type="submit" :loading="saving" block>افزودن</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<section class="card">
|
||||
<h3>تیمها</h3>
|
||||
<UiEmptyState
|
||||
<a-divider orientation="right">تیمها</a-divider>
|
||||
<a-spin :spinning="loading">
|
||||
<a-empty
|
||||
v-if="!loading && !teams.length"
|
||||
title="تیمی تعریف نشده"
|
||||
description="تیم بسازید تا اعضای فروش پروندههای همتیمی را ببینند."
|
||||
description="تیمی تعریف نشده. تیم بسازید تا اعضای فروش پروندههای همتیمی را ببینند."
|
||||
/>
|
||||
<div v-else class="team-list">
|
||||
<article v-for="t in teams" :key="t.id" class="team">
|
||||
<header>
|
||||
<div>
|
||||
<strong>{{ t.name }}</strong>
|
||||
<p class="muted">
|
||||
{{ t.description || '—' }}
|
||||
· {{ toFaDigits(t.memberCount) }} عضو
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="canEdit" class="actions">
|
||||
<UiButton variant="secondary" @click="startMetaEdit(t)">ویرایش</UiButton>
|
||||
<UiButton variant="secondary" @click="startEdit(t)">اعضا</UiButton>
|
||||
<UiButton variant="secondary" @click="removeTeam(t.id)">حذف</UiButton>
|
||||
</div>
|
||||
</header>
|
||||
<p class="members">
|
||||
<a-list v-else item-layout="vertical" :data-source="teams">
|
||||
<template #renderItem="{ item: t }">
|
||||
<a-list-item>
|
||||
<template v-if="canEdit" #actions>
|
||||
<a-button type="link" @click="startMetaEdit(t)">ویرایش</a-button>
|
||||
<a-button type="link" @click="startEdit(t)">اعضا</a-button>
|
||||
<a-button type="link" danger @click="removeTeam(t.id)">حذف</a-button>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
<a-space wrap>
|
||||
<a-typography-text strong>{{ t.name }}</a-typography-text>
|
||||
<a-tag :bordered="false">{{ toFaDigits(t.memberCount) }} عضو</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #description>
|
||||
<a-typography-text type="secondary">
|
||||
{{ t.description || 'بدون توضیح' }}
|
||||
·
|
||||
{{
|
||||
t.members.length
|
||||
? t.members
|
||||
@@ -406,53 +464,77 @@ onMounted(() => {
|
||||
.join('، ')
|
||||
: 'بدون عضو'
|
||||
}}
|
||||
</p>
|
||||
</a-typography-text>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
|
||||
<div v-if="editingMetaId === t.id" class="editor">
|
||||
<div class="row">
|
||||
<UiField label="نام تیم">
|
||||
<UiInput v-model="editName" />
|
||||
</UiField>
|
||||
<UiField label="توضیح">
|
||||
<UiInput v-model="editDesc" />
|
||||
</UiField>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<UiButton :loading="saving" @click="saveMeta">ذخیره</UiButton>
|
||||
<UiButton variant="secondary" @click="editingMetaId = null">انصراف</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
<a-form
|
||||
v-if="editingMetaId === t.id"
|
||||
layout="vertical"
|
||||
style="width: 100%"
|
||||
@finish="saveMeta"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام تیم">
|
||||
<a-input v-model:value="editName" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="توضیح">
|
||||
<a-input v-model:value="editDesc" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ذخیره</a-button>
|
||||
<a-button @click="editingMetaId = null">انصراف</a-button>
|
||||
</a-space>
|
||||
</a-form>
|
||||
|
||||
<div v-if="editingId === t.id" class="editor">
|
||||
<p class="hint">اعضا را انتخاب کنید؛ سرپرست همان عضو تیم است.</p>
|
||||
<label v-for="u in users.filter((x) => x.isActive !== false)" :key="u.id" class="person">
|
||||
<input
|
||||
type="checkbox"
|
||||
<a-flex v-if="editingId === t.id" vertical :gap="8" style="width: 100%">
|
||||
<a-typography-text type="secondary">
|
||||
اعضا را انتخاب کنید؛ سرپرست همان عضو تیم است.
|
||||
</a-typography-text>
|
||||
<a-row :gutter="[12, 8]">
|
||||
<a-col
|
||||
v-for="u in users.filter((x) => x.isActive !== false)"
|
||||
:key="u.id"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:lg="8"
|
||||
>
|
||||
<a-space>
|
||||
<a-checkbox
|
||||
:checked="Boolean(memberDraft[u.id])"
|
||||
@change="toggleMember(u.id)"
|
||||
/>
|
||||
<span>{{ u.fullName }}</span>
|
||||
<select
|
||||
>
|
||||
{{ u.fullName }}
|
||||
</a-checkbox>
|
||||
<a-select
|
||||
v-if="memberDraft[u.id]"
|
||||
:value="memberDraft[u.id]"
|
||||
@change="setRole(u.id, ($event.target as HTMLSelectElement).value as TeamMemberRole)"
|
||||
>
|
||||
<option value="member">عضو</option>
|
||||
<option value="manager">سرپرست</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<UiButton :loading="saving" @click="saveMembers">ذخیره اعضا</UiButton>
|
||||
<UiButton variant="secondary" @click="editingId = null">انصراف</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
:options="memberRoleOptions"
|
||||
style="width: 120px"
|
||||
@update:value="setRole(u.id, $event)"
|
||||
/>
|
||||
</a-space>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-space>
|
||||
<a-button type="primary" :loading="saving" @click="saveMembers">ذخیره اعضا</a-button>
|
||||
<a-button @click="editingId = null">انصراف</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-spin>
|
||||
|
||||
<section v-if="showPriorArchive" class="card">
|
||||
<header class="prior-head">
|
||||
<h3>بایگانی اطلاعات قبل</h3>
|
||||
<a-collapse :bordered="false" ghost>
|
||||
<a-collapse-panel v-if="showPriorArchive" key="prior" header="بایگانی اطلاعات قبل">
|
||||
<a-row :gutter="[16, 12]" align="middle">
|
||||
<a-col :xs="24" :lg="8">
|
||||
<JalaliDateInput
|
||||
:model-value="cutoffLocal"
|
||||
mode="date"
|
||||
@@ -460,258 +542,94 @@ onMounted(() => {
|
||||
:disabled="cutoffSaving || cutoffLoading"
|
||||
@update:model-value="onCutoffChange"
|
||||
/>
|
||||
</header>
|
||||
<p class="hint">
|
||||
فقط کاربرانی که تیک میخورند سوئیچ کار جاری و بایگانی را میبینند. بقیهٔ محیط مثل قبل همهٔ
|
||||
رکوردها را مطابق سطح دسترسی خودشان میبینند.
|
||||
</p>
|
||||
<div class="prior-picker">
|
||||
<UiInput v-model="priorSearch" placeholder="جستجوی کاربر…" />
|
||||
<select
|
||||
v-model="priorPick"
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="8">
|
||||
<a-input v-model:value="priorSearch" placeholder="جستجوی کاربر…" style="width: 100%" />
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="8">
|
||||
<a-select
|
||||
:value="priorPick"
|
||||
:options="availablePriorOptions"
|
||||
:disabled="!availablePriorUsers.length || Boolean(priorSavingId)"
|
||||
@change="pickPriorUser(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">
|
||||
{{ availablePriorUsers.length ? 'انتخاب کاربر برای فعالسازی' : 'کاربر دیگری پیدا نشد' }}
|
||||
</option>
|
||||
<option v-for="u in availablePriorUsers" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="selectedPriorUsers.length" class="prior-chips">
|
||||
<span v-for="u in selectedPriorUsers" :key="u.id" class="prior-chip">
|
||||
{{ u.fullName }}
|
||||
<button
|
||||
type="button"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
:placeholder="
|
||||
availablePriorUsers.length ? 'فعالسازی کاربر' : 'کاربر دیگری پیدا نشد'
|
||||
"
|
||||
style="width: 100%"
|
||||
@update:value="pickPriorUser"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-flex vertical :gap="8">
|
||||
<a-typography-text type="secondary">
|
||||
فقط این کاربران سوئیچ کار جاری / بایگانی را میبینند.
|
||||
</a-typography-text>
|
||||
<a-space v-if="selectedPriorUsers.length" wrap>
|
||||
<a-tag
|
||||
v-for="u in selectedPriorUsers"
|
||||
:key="u.id"
|
||||
closable
|
||||
:disabled="priorSavingId === u.id"
|
||||
:aria-label="`حذف ${u.fullName}`"
|
||||
@click="setPriorArchive(u.id, false)"
|
||||
@close.prevent="setPriorArchive(u.id, false)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<p v-else class="hint">هنوز کاربری برای دیدن سوئیچ بایگانی انتخاب نشده است.</p>
|
||||
</section>
|
||||
{{ u.fullName }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
<a-typography-text v-else type="secondary">
|
||||
هنوز کاربری انتخاب نشده است.
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-collapse-panel>
|
||||
|
||||
<section class="card">
|
||||
<h3>محدوده داده</h3>
|
||||
<p class="hint">
|
||||
مالک و مدیر همیشه همه را میبینند. پیشفرض از نقش میآید؛ برای کاربر خاص میتوانید فیلتر
|
||||
جدا بگذارید.
|
||||
</p>
|
||||
<UiEmptyState
|
||||
<a-collapse-panel key="access" header="محدوده داده کاربران">
|
||||
<a-flex vertical :gap="8">
|
||||
<a-typography-text type="secondary">
|
||||
پیشفرض از نقش است؛ برای کاربر خاص میتوانید فیلتر جدا بگذارید.
|
||||
</a-typography-text>
|
||||
<a-empty
|
||||
v-if="!editableUsers.length"
|
||||
title="کاربر قابل تنظیم نیست"
|
||||
description="فقط نقشهای فروش و پشتیبانی محدوده داده جدا دارند."
|
||||
/>
|
||||
<div v-else class="vis-table">
|
||||
<div v-for="u in editableUsers" :key="u.id" class="vis-user">
|
||||
<div class="vis-row">
|
||||
<span>
|
||||
{{ u.fullName }}
|
||||
<small class="vis-badge">{{ accessSummary(u) }}</small>
|
||||
</span>
|
||||
<UiButton v-if="canEdit" variant="secondary" @click="openAccessEditor(u)">
|
||||
{{ expandedAccess === u.id ? 'بستن' : 'تنظیم محدوده' }}
|
||||
</UiButton>
|
||||
</div>
|
||||
<div v-if="expandedAccess === u.id" class="vis-scopes">
|
||||
<label class="chip-row">
|
||||
<input v-model="accessMode" type="radio" value="inherit" />
|
||||
مثل نقش (بدون override)
|
||||
</label>
|
||||
<label class="chip-row">
|
||||
<input v-model="accessMode" type="radio" value="custom" />
|
||||
سفارشی برای این کاربر
|
||||
</label>
|
||||
<a-list v-else item-layout="vertical" :data-source="editableUsers">
|
||||
<template #renderItem="{ item: u }">
|
||||
<a-list-item>
|
||||
<template v-if="canEdit" #actions>
|
||||
<a-button type="link" @click="openAccessEditor(u)">
|
||||
{{ expandedAccess === u.id ? 'بستن' : 'تنظیم' }}
|
||||
</a-button>
|
||||
</template>
|
||||
<a-list-item-meta :description="accessSummary(u)">
|
||||
<template #title>
|
||||
<a-typography-text strong>{{ u.fullName }}</a-typography-text>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<a-flex v-if="expandedAccess === u.id" vertical :gap="8" style="width: 100%">
|
||||
<a-radio-group v-model:value="accessMode">
|
||||
<a-space wrap>
|
||||
<a-radio value="inherit">مثل نقش</a-radio>
|
||||
<a-radio value="custom">سفارشی</a-radio>
|
||||
</a-space>
|
||||
</a-radio-group>
|
||||
<DataAccessPolicyPanel
|
||||
v-if="accessMode === 'custom'"
|
||||
v-model="draftAccess"
|
||||
compact
|
||||
/>
|
||||
<div class="actions">
|
||||
<UiButton :loading="accessSaving" @click="saveUserAccess(u.id)">ذخیره</UiButton>
|
||||
<UiButton variant="secondary" @click="expandedAccess = null">انصراف</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button type="primary" :loading="accessSaving" @click="saveUserAccess(u.id)">
|
||||
ذخیره
|
||||
</a-button>
|
||||
<a-button @click="expandedAccess = null">انصراف</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-flex>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.teams-panel {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: var(--radius, 12px);
|
||||
padding: 1rem;
|
||||
}
|
||||
.card h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.prior-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.prior-head h3 {
|
||||
margin: 0;
|
||||
}
|
||||
.prior-head :deep(.trigger) {
|
||||
min-width: 12rem;
|
||||
}
|
||||
.prior-picker {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 1fr) minmax(13rem, 1fr);
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.prior-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.prior-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid #ccfbf1;
|
||||
border-radius: 999px;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prior-chip button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
.prior-chip button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: wait;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
.team-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.team {
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: var(--radius, 12px);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.team header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.muted,
|
||||
.hint,
|
||||
.members {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
.editor {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #eef2f7;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.person {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
.vis-table {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.vis-user {
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding-bottom: 0.45rem;
|
||||
}
|
||||
.vis-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.45rem 0;
|
||||
}
|
||||
.vis-badge {
|
||||
margin-inline-start: 0.4rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.vis-scopes {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.chip-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
select {
|
||||
font: inherit;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
background: #fff;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.row,
|
||||
.vis-row,
|
||||
.prior-picker {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, type Component } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue';
|
||||
import AutomationNode from '@/components/automations/AutomationNode.vue';
|
||||
import {
|
||||
AUTOMATION_ACTION_LABELS,
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
ExpandOutlined,
|
||||
FilterOutlined,
|
||||
FlagOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
GiftOutlined,
|
||||
PlusOutlined,
|
||||
SelectOutlined,
|
||||
@@ -56,8 +58,15 @@ const props = withDefaults(
|
||||
form?: LeadFormDto | null;
|
||||
users?: OrgUserDto[];
|
||||
teams?: TeamDto[];
|
||||
fullscreen?: boolean;
|
||||
}>(),
|
||||
{ chainMode: 'stop', form: null, users: () => [], teams: () => [] },
|
||||
{
|
||||
chainMode: 'stop',
|
||||
form: null,
|
||||
users: () => [],
|
||||
teams: () => [],
|
||||
fullscreen: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -66,6 +75,7 @@ const emit = defineEmits<{
|
||||
remove: [id: string];
|
||||
removeRange: [start: number, end: number];
|
||||
addBranch: [];
|
||||
'update:fullscreen': [value: boolean];
|
||||
}>();
|
||||
|
||||
const addAt = ref<{ key: string; index: number; allow: AddAllow } | null>(null);
|
||||
@@ -169,6 +179,21 @@ function onViewportClick() {
|
||||
addAt.value = null;
|
||||
}
|
||||
|
||||
function onDocPointerDown(e: PointerEvent) {
|
||||
if (!addAt.value) return;
|
||||
const target = e.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
if (target.closest('.picker') || target.closest('.plus')) return;
|
||||
addAt.value = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onDocPointerDown, true);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocPointerDown, true);
|
||||
});
|
||||
|
||||
const layout = computed((): { main: LaneItem[]; branches: BranchLane[] } => {
|
||||
const steps = props.steps;
|
||||
const { prefixEnd, branches } = splitAutomationBranches(steps);
|
||||
@@ -395,6 +420,7 @@ function removeBranch(lane: BranchLane) {
|
||||
>
|
||||
<PlusOutlined :style="{ fontSize: item.end ? '16px' : '14px' }" />
|
||||
</button>
|
||||
<Transition name="auto-picker">
|
||||
<div
|
||||
v-if="addAt?.key === item.key"
|
||||
class="picker"
|
||||
@@ -431,6 +457,7 @@ function removeBranch(lane: BranchLane) {
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<AutomationNode
|
||||
v-else
|
||||
@@ -474,6 +501,7 @@ function removeBranch(lane: BranchLane) {
|
||||
>
|
||||
<PlusOutlined :style="{ fontSize: '14px' }" />
|
||||
</button>
|
||||
<Transition name="auto-picker">
|
||||
<div
|
||||
v-if="addAt?.key === item.key"
|
||||
class="picker"
|
||||
@@ -510,6 +538,7 @@ function removeBranch(lane: BranchLane) {
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<AutomationNode
|
||||
v-else
|
||||
@@ -582,6 +611,19 @@ function removeBranch(lane: BranchLane) {
|
||||
<template #icon><ExpandOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-divider type="vertical" :style="{ height: '20px', margin: 0 }" />
|
||||
<a-tooltip :title="fullscreen ? 'خروج از تمامصفحه' : 'تمامصفحه'">
|
||||
<a-button
|
||||
type="text"
|
||||
:class="{ on: fullscreen }"
|
||||
@click="emit('update:fullscreen', !fullscreen)"
|
||||
>
|
||||
<template #icon>
|
||||
<FullscreenExitOutlined v-if="fullscreen" />
|
||||
<FullscreenOutlined v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
@@ -892,6 +934,28 @@ strong {
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.auto-picker-enter-active,
|
||||
.auto-picker-leave-active {
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
transform-origin: top center;
|
||||
will-change: opacity, transform;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.auto-picker-enter-from,
|
||||
.auto-picker-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(6px) scale(0.97);
|
||||
}
|
||||
|
||||
.auto-picker-enter-to,
|
||||
.auto-picker-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.picker-head {
|
||||
padding: 6px 8px 8px;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -9,8 +9,31 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { UiButton, UiField, UiSelect } from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Descriptions as ADescriptions,
|
||||
DescriptionsItem as ADescriptionsItem,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Steps as ASteps,
|
||||
Step as AStep,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
AimOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
|
||||
|
||||
type Step = 'target' | 'map' | 'review';
|
||||
|
||||
@@ -31,10 +54,23 @@ const stageMap = ref<Record<string, string>>({});
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const stepIndex = computed(() => {
|
||||
if (step.value === 'target') return 0;
|
||||
if (step.value === 'map') return 1;
|
||||
return 2;
|
||||
});
|
||||
|
||||
const selectedTarget = computed(
|
||||
() => preview.value?.targets.find((target) => target.id === targetId.value) ?? null,
|
||||
);
|
||||
|
||||
const targetOptions = computed(() =>
|
||||
(preview.value?.targets ?? []).map((target) => ({
|
||||
value: target.id,
|
||||
label: target.name,
|
||||
})),
|
||||
);
|
||||
|
||||
const canContinueTarget = computed(() => Boolean(targetId.value && selectedTarget.value));
|
||||
const canContinueMap = computed(() => {
|
||||
const current = preview.value;
|
||||
@@ -71,6 +107,13 @@ function compatibleTargets(src: PipelineMergePreviewStageDto) {
|
||||
});
|
||||
}
|
||||
|
||||
function compatibleOptions(src: PipelineMergePreviewStageDto) {
|
||||
return compatibleTargets(src).map((stage) => ({
|
||||
value: stage.id,
|
||||
label: stage.name,
|
||||
}));
|
||||
}
|
||||
|
||||
function statusLabel(stage: Pick<PipelineMergePreviewStageDto, 'isWon' | 'isLost'>) {
|
||||
if (stage.isWon) return 'برد';
|
||||
if (stage.isLost) return 'باخت';
|
||||
@@ -171,298 +214,181 @@ watch(targetId, () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="merge-wizard">
|
||||
<ol class="steps" aria-label="مراحل ادغام کاریز">
|
||||
<li :class="{ active: step === 'target', done: step !== 'target' }">۱. مقصد</li>
|
||||
<li :class="{ active: step === 'map', done: step === 'review' }">۲. نگاشت</li>
|
||||
<li :class="{ active: step === 'review' }">۳. بازبینی</li>
|
||||
</ol>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-steps :current="stepIndex">
|
||||
<a-step title="مقصد" />
|
||||
<a-step title="نگاشت" />
|
||||
<a-step title="بازبینی" />
|
||||
</a-steps>
|
||||
|
||||
<p v-if="busy && !preview" class="muted">در حال آمادهسازی پیشنمایش…</p>
|
||||
|
||||
<template v-else-if="preview">
|
||||
<section v-if="step === 'target'" class="step-panel">
|
||||
<div class="intro">
|
||||
<UiIcon name="layers" :size="20" />
|
||||
<div>
|
||||
<h3>کاریز مقصد را انتخاب کنید</h3>
|
||||
<p>
|
||||
<a-spin :spinning="busy && !preview">
|
||||
<template v-if="preview">
|
||||
<a-flex v-if="step === 'target'" vertical :gap="16">
|
||||
<a-flex :gap="8" align="flex-start">
|
||||
<AppstoreOutlined />
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">
|
||||
کاریز مقصد را انتخاب کنید
|
||||
</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
معاملات و فرمهای سرنخ از «{{ preview.source.name }}» به کاریز مقصد منتقل میشوند.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
|
||||
<UiField label="کاریز مقصد" required>
|
||||
<UiSelect v-model="targetId" required>
|
||||
<option value="" disabled>انتخاب کنید</option>
|
||||
<option v-for="target in preview.targets" :key="target.id" :value="target.id">
|
||||
{{ target.name }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="کاریز مقصد" required>
|
||||
<a-select
|
||||
v-model:value="targetId"
|
||||
:options="targetOptions"
|
||||
placeholder="انتخاب کنید"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="summary-grid">
|
||||
<article>
|
||||
<strong>{{ toFaDigits(preview.dealCount) }}</strong>
|
||||
<span>معامله منتقل میشود</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong>{{ toFaDigits(preview.leadForms.length) }}</strong>
|
||||
<span>فرم سرنخ به مقصد وصل میشود</span>
|
||||
</article>
|
||||
<article v-if="preview.source.isDefault" class="warn">
|
||||
<strong>هشدار</strong>
|
||||
<span>کاریز مبدا پیشفرض است و بعد از ادغام حذف میشود.</span>
|
||||
</article>
|
||||
</div>
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-card :bordered="true">
|
||||
<a-typography-text strong>{{ toFaDigits(preview.dealCount) }}</a-typography-text>
|
||||
<br />
|
||||
<a-typography-text type="secondary">معامله منتقل میشود</a-typography-text>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-card :bordered="true">
|
||||
<a-typography-text strong>{{ toFaDigits(preview.leadForms.length) }}</a-typography-text>
|
||||
<br />
|
||||
<a-typography-text type="secondary">فرم سرنخ به مقصد وصل میشود</a-typography-text>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col v-if="preview.source.isDefault" :xs="24" :md="8">
|
||||
<a-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
message="هشدار"
|
||||
description="کاریز مبدا پیشفرض است و بعد از ادغام حذف میشود."
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<p v-if="selectedTarget?.isDefault" class="note">مقصد انتخابشده کاریز پیشفرض است.</p>
|
||||
</section>
|
||||
<a-alert
|
||||
v-if="selectedTarget?.isDefault"
|
||||
type="info"
|
||||
show-icon
|
||||
message="مقصد انتخابشده کاریز پیشفرض است."
|
||||
/>
|
||||
</a-flex>
|
||||
|
||||
<section v-else-if="step === 'map'" class="step-panel">
|
||||
<div class="intro">
|
||||
<UiIcon name="target" :size="20" />
|
||||
<div>
|
||||
<h3>مرحلههای مبدا را نگاشت کنید</h3>
|
||||
<p>هر مرحله فقط به مرحلهای با وضعیت سازگار در مقصد وصل میشود.</p>
|
||||
</div>
|
||||
</div>
|
||||
<a-flex v-else-if="step === 'map'" vertical :gap="16">
|
||||
<a-flex :gap="8" align="flex-start">
|
||||
<AimOutlined />
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">
|
||||
مرحلههای مبدا را نگاشت کنید
|
||||
</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
هر مرحله فقط به مرحلهای با وضعیت سازگار در مقصد وصل میشود.
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
|
||||
<div class="map-list">
|
||||
<article v-for="sourceStage in preview.stages" :key="sourceStage.id" class="map-row">
|
||||
<div class="stage-info">
|
||||
<strong>{{ sourceStage.name }}</strong>
|
||||
<span>
|
||||
{{ statusLabel(sourceStage) }} · {{ toFaDigits(sourceStage.dealCount) }} معامله
|
||||
</span>
|
||||
</div>
|
||||
<UiSelect v-model="stageMap[sourceStage.id]" :error="!stageMap[sourceStage.id]">
|
||||
<option value="" disabled>مرحله مقصد</option>
|
||||
<option
|
||||
v-for="targetStage in compatibleTargets(sourceStage)"
|
||||
:key="targetStage.id"
|
||||
:value="targetStage.id"
|
||||
<a-form layout="vertical">
|
||||
<a-row
|
||||
v-for="sourceStage in preview.stages"
|
||||
:key="sourceStage.id"
|
||||
:gutter="[16, 0]"
|
||||
align="middle"
|
||||
>
|
||||
{{ targetStage.name }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :style="{ marginBottom: '12px' }">
|
||||
<a-typography-text strong>{{ sourceStage.name }}</a-typography-text>
|
||||
<br />
|
||||
<a-typography-text type="secondary">
|
||||
{{ statusLabel(sourceStage) }} · {{ toFaDigits(sourceStage.dealCount) }} معامله
|
||||
</a-typography-text>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
label="مرحله مقصد"
|
||||
required
|
||||
:validate-status="stageMap[sourceStage.id] ? undefined : 'error'"
|
||||
:style="{ marginBottom: '12px' }"
|
||||
>
|
||||
<a-select
|
||||
v-model:value="stageMap[sourceStage.id]"
|
||||
:options="compatibleOptions(sourceStage)"
|
||||
placeholder="مرحله مقصد"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-flex>
|
||||
|
||||
<section v-else class="step-panel">
|
||||
<div class="intro">
|
||||
<UiIcon name="check" :size="20" />
|
||||
<div>
|
||||
<h3>بازبینی نهایی</h3>
|
||||
<p>
|
||||
<a-flex v-else vertical :gap="16">
|
||||
<a-flex :gap="8" align="flex-start">
|
||||
<CheckCircleOutlined />
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">بازبینی نهایی</a-typography-title>
|
||||
<a-typography-paragraph type="secondary" :style="{ margin: 0 }">
|
||||
«{{ toFaDigits(preview.dealCount) }}» معامله منتقل میشود.
|
||||
{{ toFaDigits(preview.leadForms.length) }} فرم سرنخ به مقصد وصل میشود.
|
||||
کاریز «{{ preview.source.name }}» حذف میشود.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a-typography-paragraph>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
|
||||
<dl class="review-list">
|
||||
<div>
|
||||
<dt>مبدا</dt>
|
||||
<dd>{{ preview.source.name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>مقصد</dt>
|
||||
<dd>{{ selectedTarget?.name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>روش فرمها</dt>
|
||||
<dd>همه فرمهای سرنخ به مقصد وصل میشوند.</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="مبدا">{{ preview.source.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="مقصد">{{ selectedTarget?.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="روش فرمها">
|
||||
همه فرمهای سرنخ به مقصد وصل میشوند.
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<p v-else-if="!busy && !error" class="muted">
|
||||
<a-typography-text v-else-if="!busy && !error" type="secondary">
|
||||
<template v-if="sourceId">در حال بارگذاری پیشنمایش ادغام…</template>
|
||||
<template v-else>برای شروع ادغام، از فهرست کاریزها دکمهٔ «ادغام» را بزنید.</template>
|
||||
</p>
|
||||
</a-typography-text>
|
||||
</a-spin>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<div class="actions">
|
||||
<UiButton variant="ghost" :disabled="busy" @click="emit('close')">بستن</UiButton>
|
||||
<UiButton v-if="step === 'map'" variant="ghost" :disabled="busy" @click="step = 'target'">
|
||||
قبلی
|
||||
</UiButton>
|
||||
<UiButton v-else-if="step === 'review'" variant="ghost" :disabled="busy" @click="step = 'map'">
|
||||
قبلی
|
||||
</UiButton>
|
||||
<UiButton v-if="step === 'target'" :disabled="!preview || !canContinueTarget" @click="nextFromTarget">
|
||||
<a-space wrap>
|
||||
<a-button :disabled="busy" @click="emit('close')">بستن</a-button>
|
||||
<a-button v-if="step === 'map'" :disabled="busy" @click="step = 'target'">قبلی</a-button>
|
||||
<a-button v-else-if="step === 'review'" :disabled="busy" @click="step = 'map'">قبلی</a-button>
|
||||
<a-button
|
||||
v-if="step === 'target'"
|
||||
type="primary"
|
||||
:disabled="!preview || !canContinueTarget"
|
||||
@click="nextFromTarget"
|
||||
>
|
||||
ادامه
|
||||
</UiButton>
|
||||
<UiButton v-else-if="step === 'map'" :disabled="!canContinueMap" @click="nextFromMap">
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else-if="step === 'map'"
|
||||
type="primary"
|
||||
:disabled="!canContinueMap"
|
||||
@click="nextFromMap"
|
||||
>
|
||||
بازبینی
|
||||
</UiButton>
|
||||
<UiButton v-else variant="danger" :loading="busy" :disabled="!canContinueMap" @click="submitMerge">
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else
|
||||
type="primary"
|
||||
danger
|
||||
:loading="busy"
|
||||
:disabled="!canContinueMap"
|
||||
@click="submitMerge"
|
||||
>
|
||||
ادغام و حذف مبدا
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.merge-wizard {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.steps li {
|
||||
padding: 0.3rem 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.steps li.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.steps li.done {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.step-panel {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.intro {
|
||||
display: flex;
|
||||
gap: 0.65rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.intro h3,
|
||||
.intro p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.intro h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.intro p {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
}
|
||||
|
||||
.summary-grid article {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.summary-grid strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.summary-grid span,
|
||||
.muted,
|
||||
.note {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.summary-grid .warn {
|
||||
border-color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.map-list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.map-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(12rem, 18rem);
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.stage-info {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stage-info span {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.review-list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.review-list div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-bottom: 0.55rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.review-list dt,
|
||||
.review-list dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.review-list dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.map-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,9 +23,7 @@ withDefaults(
|
||||
<div class="titles">
|
||||
<h1 v-if="title" class="title">{{ title }}</h1>
|
||||
<slot v-else name="title" />
|
||||
|
||||
<p v-if="lead || description" class="lead">{{ lead || description }}</p>
|
||||
<slot v-else name="lead" />
|
||||
<slot name="lead" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,13 +85,6 @@ withDefaults(
|
||||
line-height: var(--lh-tight);
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
line-height: var(--lh-normal);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,27 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import type { NoteAttachmentDto, NoteDto, PaginatedResponse } from '@mento/shared';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Spin as ASpin,
|
||||
Table as ATable,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { DownloadOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Link: ATypographyLink } = Typography;
|
||||
|
||||
type Row = NoteAttachmentDto & {
|
||||
noteId: string;
|
||||
noteTitle: string;
|
||||
};
|
||||
|
||||
const ATT_COL_LABELS = ['نام فایل', 'یادداشت', 'حجم', 'تاریخ'] as const;
|
||||
|
||||
registerTableColumns('attachments', [...ATT_COL_LABELS]);
|
||||
const tableCols = useTableColumns(() => 'attachments');
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const items = ref<Row[]>([]);
|
||||
|
||||
const listEmpty = computed(() => !loading.value && !items.value.length);
|
||||
|
||||
const columns = computed<TableColumnsType<Row>>(() => {
|
||||
const all: TableColumnsType<Row> = [
|
||||
{ title: 'نام فایل', key: 'نام فایل', dataIndex: 'fileName' },
|
||||
{ title: 'یادداشت', key: 'یادداشت' },
|
||||
{ title: 'حجم', key: 'حجم' },
|
||||
{ title: 'تاریخ', key: 'تاریخ' },
|
||||
{ title: '', key: 'عملیات', width: 110 },
|
||||
];
|
||||
return all.filter((col) => {
|
||||
const key = String(col.key ?? '');
|
||||
if (key === 'عملیات') return true;
|
||||
if (!ATT_COL_LABELS.includes(key as (typeof ATT_COL_LABELS)[number])) return true;
|
||||
return tableCols.isVisible(key);
|
||||
});
|
||||
});
|
||||
|
||||
function sizeLabel(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function rowKey(row: Row) {
|
||||
return `${row.noteId}-${row.id}`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -62,62 +104,54 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page" dir="rtl">
|
||||
<header class="head">
|
||||
<div>
|
||||
<h1>پیوستها</h1>
|
||||
<p class="sub">فایلهای پیوستشده به یادداشتها</p>
|
||||
</div>
|
||||
</header>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<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">
|
||||
<div class="tc-row">
|
||||
<TableColumnsMenu table-key="attachments" align="end" />
|
||||
</div>
|
||||
<table v-table-cols="'attachments'">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>نام فایل</th>
|
||||
<th>یادداشت</th>
|
||||
<th>حجم</th>
|
||||
<th>تاریخ</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in items" :key="`${row.noteId}-${row.id}`">
|
||||
<td>{{ row.fileName }}</td>
|
||||
<td>
|
||||
<RouterLink :to="{ name: 'notes' }">
|
||||
{{ row.noteTitle }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td>{{ sizeLabel(row.sizeBytes) }}</td>
|
||||
<td>{{ formatJalaliNumeric(new Date(row.createdAt)) }}</td>
|
||||
<td>
|
||||
<button type="button" @click="download(row)">دانلود</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">پیوستها</a-typography-title>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
h1 { margin: 0; font-size: 1.25rem; }
|
||||
.sub, .muted { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.88rem; }
|
||||
.error { color: var(--danger, #dc2626); font-weight: 600; }
|
||||
.table-wrap { overflow: auto; background: #fff; border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 640px; }
|
||||
th, td { padding: 0.7rem 0.65rem; border-bottom: 1px solid #eef2f7; text-align: right; }
|
||||
th { background: #f8fafc; color: #64748b; font-size: 0.82rem; }
|
||||
a { color: #0f766e; font-weight: 700; }
|
||||
button {
|
||||
min-height: var(--control-h); border: 1px solid var(--line); border-radius: var(--radius);
|
||||
padding: 0 0.75rem; background: #fff; cursor: pointer; font: inherit;
|
||||
}
|
||||
</style>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-card v-if="listEmpty && !error" :bordered="false">
|
||||
<a-empty description="پیوستی ثبت نشده." />
|
||||
</a-card>
|
||||
|
||||
<a-card v-else-if="!error && items.length" :bordered="false">
|
||||
<a-flex justify="flex-end" :style="{ marginBottom: '12px' }">
|
||||
<TableColumnsMenu table-key="attachments" align="end" />
|
||||
</a-flex>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="items"
|
||||
:pagination="false"
|
||||
:row-key="rowKey"
|
||||
:scroll="{ x: 640 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'نام فایل'">
|
||||
{{ (record as Row).fileName }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'یادداشت'">
|
||||
<RouterLink :to="{ name: 'notes' }" custom v-slot="{ href, navigate }">
|
||||
<a-typography-link :href="href" @click="navigate">
|
||||
{{ (record as Row).noteTitle }}
|
||||
</a-typography-link>
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'حجم'">
|
||||
{{ sizeLabel((record as Row).sizeBytes) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'تاریخ'">
|
||||
{{ formatJalaliNumeric(new Date((record as Row).createdAt)) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'عملیات'">
|
||||
<a-button @click="download(record as Row)">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
دانلود
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -13,21 +13,45 @@ import type {
|
||||
DeletionArchiveListResponse,
|
||||
DeletionRetention,
|
||||
} from '@mento/shared';
|
||||
import { formatClockFa, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiModal,
|
||||
UiPageHeader,
|
||||
UiPagination,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { formatClockFa, formatJalaliNumeric } from '@/lib/jalali';
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
Modal as AModal,
|
||||
Pagination as APagination,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Table as ATable,
|
||||
Tabs as ATabs,
|
||||
TabPane as ATabPane,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
type LogTab = 'all' | 'creates' | 'edits' | 'deletes' | 'attendance';
|
||||
|
||||
type AttendanceRow = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
kind: 'login' | 'logout' | 'forced_logout';
|
||||
at: string;
|
||||
afterHours: boolean;
|
||||
};
|
||||
|
||||
const TABS: Array<{ key: LogTab; label: string; action?: string }> = [
|
||||
{ key: 'all', label: 'همه' },
|
||||
{ key: 'attendance', label: 'ورود و خروج' },
|
||||
@@ -61,6 +85,10 @@ const RETENTION_OPTIONS: Array<{ value: DeletionRetention; label: string }> = [
|
||||
{ value: 'never', label: 'هرگز' },
|
||||
];
|
||||
|
||||
const AUDIT_COL_LABELS = ['زمان', 'کاربر', 'عملیات', 'موضوع', 'شرح'] as const;
|
||||
registerTableColumns('audit-logs', [...AUDIT_COL_LABELS]);
|
||||
const tableCols = useTableColumns(() => 'audit-logs');
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const isOwner = computed(() => auth.user?.role === 'owner');
|
||||
@@ -84,16 +112,7 @@ const page = ref(1);
|
||||
const pageSize = usePageSize('audit-logs');
|
||||
const data = ref<AuditLogListResponse | null>(null);
|
||||
const deletions = ref<DeletionArchiveListResponse | null>(null);
|
||||
const attendance = ref<{
|
||||
items: Array<{
|
||||
id: string;
|
||||
fullName: string;
|
||||
kind: 'login' | 'logout' | 'forced_logout';
|
||||
at: string;
|
||||
afterHours: boolean;
|
||||
}>;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const attendance = ref<{ items: AttendanceRow[]; total: number } | null>(null);
|
||||
const retention = ref<DeletionRetention>('never');
|
||||
const purgeId = ref<string | null>(null);
|
||||
|
||||
@@ -118,6 +137,72 @@ const purgeItem = computed(
|
||||
() => deletions.value?.items.find((item) => item.id === purgeId.value) ?? null,
|
||||
);
|
||||
|
||||
const actorOptions = computed(() => [
|
||||
{ value: '', label: 'همه' },
|
||||
...actors.value.map((user) => ({ value: user.id, label: user.fullName })),
|
||||
]);
|
||||
|
||||
const auditColumns = computed<TableColumnsType<AuditLogDto>>(() => {
|
||||
const all: TableColumnsType<AuditLogDto> = [
|
||||
{ title: 'زمان', key: 'زمان', width: 120 },
|
||||
{ title: 'کاربر', key: 'کاربر' },
|
||||
{ title: 'عملیات', key: 'عملیات', width: 110 },
|
||||
{ title: 'موضوع', key: 'موضوع' },
|
||||
{ title: 'شرح', key: 'شرح' },
|
||||
{ title: '', key: 'مشاهده', width: 100 },
|
||||
];
|
||||
return all.filter((col) => {
|
||||
const key = String(col.key ?? '');
|
||||
if (key === 'مشاهده') return true;
|
||||
if (!AUDIT_COL_LABELS.includes(key as (typeof AUDIT_COL_LABELS)[number])) return true;
|
||||
return tableCols.isVisible(key);
|
||||
});
|
||||
});
|
||||
|
||||
const deletionColumns: TableColumnsType<DeletionArchiveDto> = [
|
||||
{ title: 'زمان', key: 'زمان', width: 120 },
|
||||
{ title: 'انجامدهنده', key: 'انجامدهنده' },
|
||||
{ title: 'نوع', key: 'نوع' },
|
||||
{ title: 'عنوان', key: 'عنوان' },
|
||||
{ title: 'شرح', key: 'شرح' },
|
||||
{ title: '', key: 'عملیات', width: 220 },
|
||||
];
|
||||
|
||||
const attendanceColumns: TableColumnsType<AttendanceRow> = [
|
||||
{ title: 'زمان', key: 'زمان', width: 120 },
|
||||
{ title: 'کاربر', key: 'کاربر' },
|
||||
{ title: 'رویداد', key: 'رویداد' },
|
||||
];
|
||||
|
||||
const initialLoading = computed(
|
||||
() => loading.value && !data.value && !deletions.value && !attendance.value,
|
||||
);
|
||||
|
||||
const showAuditEmpty = computed(
|
||||
() =>
|
||||
!showingArchive.value &&
|
||||
!showingAttendance.value &&
|
||||
!loading.value &&
|
||||
data.value &&
|
||||
!data.value.items.length,
|
||||
);
|
||||
|
||||
const showArchiveEmpty = computed(
|
||||
() =>
|
||||
showingArchive.value &&
|
||||
!loading.value &&
|
||||
deletions.value &&
|
||||
!deletions.value.items.length,
|
||||
);
|
||||
|
||||
const showAttendanceEmpty = computed(
|
||||
() =>
|
||||
showingAttendance.value &&
|
||||
!loading.value &&
|
||||
attendance.value &&
|
||||
!attendance.value.items.length,
|
||||
);
|
||||
|
||||
function entityLabel(entityType: string | null) {
|
||||
if (!entityType) return 'مورد';
|
||||
return ENTITY_LABELS[entityType] ?? 'مورد';
|
||||
@@ -283,9 +368,10 @@ function attendanceLabel(kind: string, afterHours: boolean) {
|
||||
return 'خروج';
|
||||
}
|
||||
|
||||
function selectTab(next: LogTab) {
|
||||
if (tab.value === next) return;
|
||||
tab.value = next;
|
||||
function selectTab(next: string | number) {
|
||||
const key = String(next) as LogTab;
|
||||
if (tab.value === key) return;
|
||||
tab.value = key;
|
||||
okMsg.value = '';
|
||||
data.value = null;
|
||||
deletions.value = null;
|
||||
@@ -293,14 +379,16 @@ function selectTab(next: LogTab) {
|
||||
void load(true);
|
||||
}
|
||||
|
||||
function onPage(next: number) {
|
||||
page.value = next;
|
||||
void load();
|
||||
function onPaginationChange(nextPage: number, nextSize: number) {
|
||||
const sizeChanged = nextSize !== pageSize.value;
|
||||
page.value = nextPage;
|
||||
if (sizeChanged) {
|
||||
pageSize.value = nextSize;
|
||||
if (page.value !== 1) {
|
||||
page.value = 1;
|
||||
}
|
||||
|
||||
function onPageSize(size: number) {
|
||||
pageSize.value = size;
|
||||
if (page.value === 1) void load();
|
||||
}
|
||||
void load(sizeChanged);
|
||||
}
|
||||
|
||||
async function saveRetention() {
|
||||
@@ -350,6 +438,14 @@ async function confirmPurge() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatWhen(iso: string) {
|
||||
const d = new Date(iso);
|
||||
return {
|
||||
clock: formatClockFa(d),
|
||||
date: formatJalaliNumeric(d),
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadActors();
|
||||
void load();
|
||||
@@ -357,503 +453,293 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="audit-page">
|
||||
<UiPageHeader
|
||||
title="لاگ فعالیت کاربران"
|
||||
description="ثبت تغییرات ایجاد، ویرایش و حذف در سامانه"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" :disabled="loading" @click="load()">
|
||||
<UiIcon name="refresh" />
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">لاگ فعالیت کاربران</a-typography-title>
|
||||
<a-button :disabled="loading" @click="load()">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<nav class="tabs" aria-label="نوع رویداد">
|
||||
<button
|
||||
v-for="item in visibleTabs"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
:class="{ on: tab === item.key }"
|
||||
@click="selectTab(item.key)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</nav>
|
||||
<a-tabs :active-key="tab" @change="selectTab">
|
||||
<a-tab-pane v-for="item in visibleTabs" :key="item.key" :tab="item.label" />
|
||||
</a-tabs>
|
||||
|
||||
<UiCard v-if="showingArchive">
|
||||
<div class="retention">
|
||||
<label for="audit-retention">حذف خودکار از این فهرست</label>
|
||||
<select id="audit-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">
|
||||
<a-card v-if="showingArchive" :bordered="false">
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]" align="bottom">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="حذف خودکار از این فهرست">
|
||||
<a-select
|
||||
v-model:value="retention"
|
||||
:options="RETENTION_OPTIONS"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" :loading="savingRetention" @click="saveRetention">
|
||||
ذخیره
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
<a-typography-text type="secondary">
|
||||
تا پایان این مدت میتوان مورد را برگرداند. «هرگز» یعنی فقط با حذف کامل از بین میرود.
|
||||
</p>
|
||||
</UiCard>
|
||||
</a-typography-text>
|
||||
</a-card>
|
||||
|
||||
<UiCard class="filters">
|
||||
<form class="filters-grid" @submit.prevent="load(true)">
|
||||
<UiField label="جستجو">
|
||||
<UiInput v-model="q" placeholder="نام، عنوان یا شرح تغییر…" />
|
||||
</UiField>
|
||||
<UiField label="انجامدهنده">
|
||||
<select v-model="actorId" class="actor" @change="load(true)">
|
||||
<option value="">همه</option>
|
||||
<option v-for="user in actors" :key="user.id" :value="user.id">{{ user.fullName }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<div class="filters-actions">
|
||||
<UiButton type="submit" :disabled="loading">اعمال فیلتر</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</UiCard>
|
||||
|
||||
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<UiCard v-if="loading && !data && !deletions" title="در حال بارگذاری…">
|
||||
<p class="muted">لطفاً صبر کنید.</p>
|
||||
</UiCard>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="showingArchive && !loading && deletions && !deletions.items.length"
|
||||
title="حذفی در این فهرست نیست"
|
||||
description="حذفها با نام انجامدهنده اینجا میمانند و تا پایان مدت نگهداری قابل بازگشتاند."
|
||||
<a-card :bordered="false">
|
||||
<a-form layout="vertical" @finish="load(true)">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="جستجو">
|
||||
<a-input v-model:value="q" placeholder="نام، عنوان یا شرح تغییر…" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="انجامدهنده">
|
||||
<a-select
|
||||
v-model:value="actorId"
|
||||
:options="actorOptions"
|
||||
style="width: 100%"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
@change="load(true)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="4">
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" html-type="submit" :disabled="loading" block>
|
||||
اعمال فیلتر
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="showingAttendance && !loading && attendance && !attendance.items.length"
|
||||
title="ورود و خروجی ثبت نشده"
|
||||
description="با ورود یا خروج کارمندان، اینجا دیده میشود."
|
||||
<a-alert v-if="okMsg" type="success" show-icon :message="okMsg" closable @close="okMsg = ''" />
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-card v-if="initialLoading" :bordered="false">
|
||||
<a-typography-text type="secondary">لطفاً صبر کنید.</a-typography-text>
|
||||
</a-card>
|
||||
|
||||
<a-card v-else-if="showArchiveEmpty" :bordered="false">
|
||||
<a-empty
|
||||
description="حذفی در این فهرست نیست. حذفها با نام انجامدهنده اینجا میمانند و تا پایان مدت نگهداری قابل بازگشتاند."
|
||||
/>
|
||||
</a-card>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!showingArchive && !showingAttendance && !loading && data && !data.items.length"
|
||||
title="رویدادی در این بخش نیست"
|
||||
description="با انجام کار در سامانه، ایجاد و ویرایش اینجا دیده میشود."
|
||||
/>
|
||||
<a-card v-else-if="showAttendanceEmpty" :bordered="false">
|
||||
<a-empty description="ورود و خروجی ثبت نشده. با ورود یا خروج کارمندان، اینجا دیده میشود." />
|
||||
</a-card>
|
||||
|
||||
<UiCard v-else-if="showingArchive && deletions?.items.length" title="حذفها">
|
||||
<div class="table-wrap">
|
||||
<table data-no-rownum>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="when-col">زمان</th>
|
||||
<th>انجامدهنده</th>
|
||||
<th>نوع</th>
|
||||
<th>عنوان</th>
|
||||
<th>شرح</th>
|
||||
<th class="actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in deletions.items" :key="item.id">
|
||||
<td class="when" data-label="زمان">
|
||||
<span class="clock">{{ formatClockFa(new Date(item.deletedAt)) }}</span>
|
||||
<span>{{ formatJalaliNumeric(new Date(item.deletedAt)) }}</span>
|
||||
</td>
|
||||
<td data-label="انجامدهنده">{{ item.deletedByName || 'سیستم' }}</td>
|
||||
<td data-label="نوع">{{ item.modelLabel }}</td>
|
||||
<td data-label="عنوان">
|
||||
<span class="clip" :title="item.label">{{ item.label }}</span>
|
||||
</td>
|
||||
<td data-label="شرح">
|
||||
<span class="clip" :title="deletionStory(item)">{{ deletionStory(item) }}</span>
|
||||
</td>
|
||||
<td class="ops" data-label="عملیات">
|
||||
<UiButton size="sm" variant="secondary" :disabled="busyId === item.id" @click="restore(item)">
|
||||
<a-card v-else-if="showAuditEmpty" :bordered="false">
|
||||
<a-empty description="رویدادی در این بخش نیست. با انجام کار در سامانه، ایجاد و ویرایش اینجا دیده میشود." />
|
||||
</a-card>
|
||||
|
||||
<a-card
|
||||
v-else-if="showingArchive && deletions?.items.length"
|
||||
title="حذفها"
|
||||
:bordered="false"
|
||||
>
|
||||
<a-table
|
||||
:columns="deletionColumns"
|
||||
:data-source="deletions.items"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: 720 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'زمان'">
|
||||
<a-flex vertical>
|
||||
<a-typography-text strong>{{ formatWhen((record as DeletionArchiveDto).deletedAt).clock }}</a-typography-text>
|
||||
<a-typography-text type="secondary">
|
||||
{{ formatWhen((record as DeletionArchiveDto).deletedAt).date }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'انجامدهنده'">
|
||||
{{ (record as DeletionArchiveDto).deletedByName || 'سیستم' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'نوع'">
|
||||
{{ (record as DeletionArchiveDto).modelLabel }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'عنوان'">
|
||||
{{ (record as DeletionArchiveDto).label }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'شرح'">
|
||||
{{ deletionStory(record as DeletionArchiveDto) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'عملیات'">
|
||||
<a-space wrap>
|
||||
<a-button
|
||||
:disabled="busyId === (record as DeletionArchiveDto).id"
|
||||
@click="restore(record as DeletionArchiveDto)"
|
||||
>
|
||||
بازگردانی
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="danger" :disabled="busyId === item.id" @click="purgeId = item.id">
|
||||
</a-button>
|
||||
<a-button
|
||||
danger
|
||||
:disabled="busyId === (record as DeletionArchiveDto).id"
|
||||
@click="purgeId = (record as DeletionArchiveDto).id"
|
||||
>
|
||||
حذف کامل
|
||||
</UiButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<UiPagination
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
:total-items="deletions.total"
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8" :style="{ marginTop: '12px' }">
|
||||
<a-pagination
|
||||
:current="page"
|
||||
:total="deletions.total"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="PAGE_SIZE_OPTIONS"
|
||||
@update:page="onPage"
|
||||
@update:page-size="onPageSize"
|
||||
:show-size-changer="true"
|
||||
:page-size-options="PAGE_SIZE_OPTIONS.map(String)"
|
||||
@change="onPaginationChange"
|
||||
/>
|
||||
</UiCard>
|
||||
<a-typography-text type="secondary">
|
||||
صفحه {{ toFaDigits(page) }} از {{ toFaDigits(totalPages) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
<UiCard v-else-if="showingAttendance && attendance?.items.length" title="ورود و خروج">
|
||||
<div class="table-wrap">
|
||||
<table data-no-rownum>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="when-col">زمان</th>
|
||||
<th>کاربر</th>
|
||||
<th>رویداد</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in attendance.items" :key="item.id">
|
||||
<td class="when" data-label="زمان">
|
||||
<span class="clock">{{ formatClockFa(new Date(item.at)) }}</span>
|
||||
<span>{{ formatJalaliNumeric(new Date(item.at)) }}</span>
|
||||
</td>
|
||||
<td data-label="کاربر">{{ item.fullName }}</td>
|
||||
<td data-label="رویداد">{{ attendanceLabel(item.kind, item.afterHours) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<UiPagination
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
:total-items="attendance.total"
|
||||
<a-card
|
||||
v-else-if="showingAttendance && attendance?.items.length"
|
||||
title="ورود و خروج"
|
||||
:bordered="false"
|
||||
>
|
||||
<a-table
|
||||
:columns="attendanceColumns"
|
||||
:data-source="attendance.items"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: 480 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'زمان'">
|
||||
<a-flex vertical>
|
||||
<a-typography-text strong>
|
||||
{{ formatWhen((record as AttendanceRow).at).clock }}
|
||||
</a-typography-text>
|
||||
<a-typography-text type="secondary">
|
||||
{{ formatWhen((record as AttendanceRow).at).date }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'کاربر'">
|
||||
{{ (record as AttendanceRow).fullName }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'رویداد'">
|
||||
{{
|
||||
attendanceLabel(
|
||||
(record as AttendanceRow).kind,
|
||||
(record as AttendanceRow).afterHours,
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8" :style="{ marginTop: '12px' }">
|
||||
<a-pagination
|
||||
:current="page"
|
||||
:total="attendance.total"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="PAGE_SIZE_OPTIONS"
|
||||
@update:page="onPage"
|
||||
@update:page-size="onPageSize"
|
||||
:show-size-changer="true"
|
||||
:page-size-options="PAGE_SIZE_OPTIONS.map(String)"
|
||||
@change="onPaginationChange"
|
||||
/>
|
||||
</UiCard>
|
||||
<a-typography-text type="secondary">
|
||||
صفحه {{ toFaDigits(page) }} از {{ toFaDigits(totalPages) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
<UiCard v-else-if="data?.items.length" title="رویدادها">
|
||||
<div class="tc-row">
|
||||
<a-card v-else-if="data?.items.length" title="رویدادها" :bordered="false">
|
||||
<a-flex justify="flex-end" :style="{ marginBottom: '12px' }">
|
||||
<TableColumnsMenu table-key="audit-logs" align="end" />
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table
|
||||
v-table-cols="'audit-logs'"
|
||||
data-no-rownum
|
||||
:style="{ '--row-start': (page - 1) * pageSize }"
|
||||
</a-flex>
|
||||
<a-table
|
||||
:columns="auditColumns"
|
||||
:data-source="data.items"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: 720 }"
|
||||
:custom-row="(record: AuditLogDto) => ({
|
||||
style: recordPath(record) ? { cursor: 'pointer' } : undefined,
|
||||
onClick: () => openRecord(record),
|
||||
})"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="when-col">زمان</th>
|
||||
<th>کاربر</th>
|
||||
<th>عملیات</th>
|
||||
<th>موضوع</th>
|
||||
<th>شرح</th>
|
||||
<th class="actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in data.items"
|
||||
:key="row.id"
|
||||
:class="{ link: !!recordPath(row) }"
|
||||
@click="openRecord(row)"
|
||||
>
|
||||
<td class="when" data-label="زمان">
|
||||
<span class="clock">{{ formatClockFa(new Date(row.createdAt)) }}</span>
|
||||
<span>{{ formatJalaliNumeric(new Date(row.createdAt)) }}</span>
|
||||
</td>
|
||||
<td data-label="کاربر">{{ row.actor?.fullName ?? '—' }}</td>
|
||||
<td data-label="عملیات">
|
||||
<span class="pill">{{ row.action }}</span>
|
||||
</td>
|
||||
<td data-label="موضوع">
|
||||
<span class="clip" :title="rowName(row) || entityLabel(row.entityType)">
|
||||
{{ rowName(row) || entityLabel(row.entityType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td data-label="شرح">
|
||||
<span class="clip" :title="rowStory(row)">{{ rowStory(row) }}</span>
|
||||
</td>
|
||||
<td class="ops">
|
||||
<button
|
||||
v-if="recordPath(row)"
|
||||
type="button"
|
||||
class="view"
|
||||
@click.stop="openRecord(row)"
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'زمان'">
|
||||
<a-flex vertical>
|
||||
<a-typography-text strong>{{ formatWhen((record as AuditLogDto).createdAt).clock }}</a-typography-text>
|
||||
<a-typography-text type="secondary">
|
||||
{{ formatWhen((record as AuditLogDto).createdAt).date }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'کاربر'">
|
||||
{{ (record as AuditLogDto).actor?.fullName ?? '—' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'عملیات'">
|
||||
<a-tag color="processing" :bordered="false">{{ (record as AuditLogDto).action }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'موضوع'">
|
||||
{{ rowName(record as AuditLogDto) || entityLabel((record as AuditLogDto).entityType) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'شرح'">
|
||||
{{ rowStory(record as AuditLogDto) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'مشاهده'">
|
||||
<a-button
|
||||
v-if="recordPath(record as AuditLogDto)"
|
||||
type="link"
|
||||
@click.stop="openRecord(record as AuditLogDto)"
|
||||
>
|
||||
مشاهده
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<UiPagination
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
:total-items="data.total"
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8" :style="{ marginTop: '12px' }">
|
||||
<a-pagination
|
||||
:current="page"
|
||||
:total="data.total"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="PAGE_SIZE_OPTIONS"
|
||||
@update:page="onPage"
|
||||
@update:page-size="onPageSize"
|
||||
:show-size-changer="true"
|
||||
:page-size-options="PAGE_SIZE_OPTIONS.map(String)"
|
||||
@change="onPaginationChange"
|
||||
/>
|
||||
</UiCard>
|
||||
<a-typography-text type="secondary">
|
||||
صفحه {{ toFaDigits(page) }} از {{ toFaDigits(totalPages) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
|
||||
<UiModal :open="!!purgeItem" title="حذف کامل" @close="purgeId = null">
|
||||
<p v-if="purgeItem" class="confirm">
|
||||
<a-modal
|
||||
:open="!!purgeItem"
|
||||
title="حذف کامل"
|
||||
ok-text="حذف کامل"
|
||||
cancel-text="انصراف"
|
||||
ok-type="danger"
|
||||
:confirm-loading="!!busyId"
|
||||
destroy-on-close
|
||||
@ok="confirmPurge"
|
||||
@cancel="purgeId = null"
|
||||
>
|
||||
<a-typography-text v-if="purgeItem">
|
||||
«{{ purgeItem.label }}» برای همیشه پاک شود؟ بعد از این دیگر قابل بازگشت نیست.
|
||||
</p>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" :disabled="!!busyId" @click="purgeId = null">انصراف</UiButton>
|
||||
<UiButton variant="danger" :loading="!!busyId" @click="confirmPurge">حذف کامل</UiButton>
|
||||
</a-typography-text>
|
||||
</a-modal>
|
||||
</a-flex>
|
||||
</template>
|
||||
</UiModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.audit-page {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.tabs button {
|
||||
flex: 1 1 0;
|
||||
min-width: 4.5rem;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.55rem 0.35rem 0.7rem;
|
||||
color: #64748b;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 -2px 0 transparent;
|
||||
}
|
||||
.tabs button.on {
|
||||
color: #0f766e;
|
||||
box-shadow: inset 0 -2px 0 #14b8a6;
|
||||
}
|
||||
.retention {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.retention label {
|
||||
font-weight: 700;
|
||||
}
|
||||
.retention select,
|
||||
.actor {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
padding: 0.45rem 0.7rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.retention select {
|
||||
width: auto;
|
||||
min-width: 8.5rem;
|
||||
}
|
||||
.actor {
|
||||
width: 100%;
|
||||
}
|
||||
.hint,
|
||||
.muted {
|
||||
margin: 0.55rem 0 0;
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.filters-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.filters-grid :deep(.ui-field) {
|
||||
flex: 1 1 14rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.filters-grid :deep(.ui-field:nth-child(2)) {
|
||||
flex: 0 1 16rem;
|
||||
max-width: 16rem;
|
||||
}
|
||||
.filters-actions {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
}
|
||||
.filters-actions :deep(button) {
|
||||
min-height: 2.4rem;
|
||||
}
|
||||
.ok {
|
||||
margin: 0;
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 46rem;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
table-layout: fixed;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: right;
|
||||
padding: 0.65rem 0.55rem;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.when-col,
|
||||
.when {
|
||||
width: 6.2rem;
|
||||
}
|
||||
.when span {
|
||||
display: block;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.clock {
|
||||
font-weight: 800;
|
||||
}
|
||||
.actions,
|
||||
td.ops {
|
||||
width: 11rem;
|
||||
text-align: left;
|
||||
}
|
||||
.clip {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
th {
|
||||
color: #64748b;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.detail {
|
||||
color: #334155;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: var(--radius);
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.ops {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tr.link {
|
||||
cursor: pointer;
|
||||
}
|
||||
tr.link:hover td {
|
||||
background: #f8fffd;
|
||||
}
|
||||
.view {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: #0f766e;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.confirm {
|
||||
margin: 0;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.filters-grid :deep(.ui-field),
|
||||
.filters-grid :deep(.ui-field:nth-child(2)) {
|
||||
flex: 1 1 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.filters-actions,
|
||||
.filters-actions :deep(button) {
|
||||
width: 100%;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow: visible;
|
||||
}
|
||||
table {
|
||||
min-width: 0;
|
||||
}
|
||||
table thead {
|
||||
display: none;
|
||||
}
|
||||
table,
|
||||
tbody,
|
||||
tr,
|
||||
td {
|
||||
display: block;
|
||||
width: auto;
|
||||
}
|
||||
tbody tr {
|
||||
border: 1px solid #e7eef2;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 0.65rem;
|
||||
padding: 0.35rem 0.7rem 0.55rem;
|
||||
background: #fff;
|
||||
}
|
||||
td {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
border-bottom: 0;
|
||||
padding: 0.35rem 0;
|
||||
text-align: start;
|
||||
}
|
||||
td::before {
|
||||
content: attr(data-label);
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
td.ops {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.view {
|
||||
display: none;
|
||||
}
|
||||
tr.link {
|
||||
cursor: pointer;
|
||||
}
|
||||
.when {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
width: auto;
|
||||
}
|
||||
.clip {
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import AutomationCanvas from '@/components/automations/AutomationCanvas.vue';
|
||||
@@ -85,6 +85,8 @@ const search = ref('');
|
||||
const steps = ref<AutomationStep[]>([makeStep('create_activity')]);
|
||||
const selected = ref<'trigger' | string>('trigger');
|
||||
const inspectorOpen = ref(false);
|
||||
const inspectorRef = ref<HTMLElement | null>(null);
|
||||
const canvasFullscreen = ref(false);
|
||||
const savedSnap = ref('');
|
||||
const createOpen = ref(false);
|
||||
const draftName = ref('');
|
||||
@@ -107,6 +109,38 @@ function selectNode(id: 'trigger' | string) {
|
||||
inspectorOpen.value = true;
|
||||
}
|
||||
|
||||
function isAntOverlayTarget(target: Element) {
|
||||
return !!(
|
||||
target.closest('.ant-select-dropdown') ||
|
||||
target.closest('.ant-picker-dropdown') ||
|
||||
target.closest('.ant-cascader-dropdown') ||
|
||||
target.closest('.ant-dropdown') ||
|
||||
target.closest('.ant-modal-wrap') ||
|
||||
target.closest('.ant-popover') ||
|
||||
target.closest('.ant-tooltip') ||
|
||||
target.closest('.ant-message')
|
||||
);
|
||||
}
|
||||
|
||||
function onDocPointerDown(e: PointerEvent) {
|
||||
if (!inspectorOpen.value) return;
|
||||
const root = inspectorRef.value;
|
||||
if (!root) return;
|
||||
const target = e.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
if (root.contains(target) || isAntOverlayTarget(target)) return;
|
||||
inspectorOpen.value = false;
|
||||
}
|
||||
|
||||
function onDocKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (inspectorOpen.value) {
|
||||
inspectorOpen.value = false;
|
||||
return;
|
||||
}
|
||||
if (canvasFullscreen.value) canvasFullscreen.value = false;
|
||||
}
|
||||
|
||||
const dirty = computed(() => snap() !== savedSnap.value);
|
||||
const selectedForm = computed(() =>
|
||||
trigger.value === 'lead_submitted' && sourceId.value
|
||||
@@ -199,6 +233,8 @@ function errText(e: unknown, fallback: string) {
|
||||
function hydrate(row?: AutomationRuleDto | null) {
|
||||
if (!row) {
|
||||
editingId.value = null;
|
||||
canvasFullscreen.value = false;
|
||||
inspectorOpen.value = false;
|
||||
name.value = 'قانون بدون نام';
|
||||
trigger.value = 'deal_won';
|
||||
isActive.value = true;
|
||||
@@ -493,6 +529,7 @@ function removeRule(id: string) {
|
||||
if (editingId.value === id) {
|
||||
editingId.value = null;
|
||||
inspectorOpen.value = false;
|
||||
canvasFullscreen.value = false;
|
||||
}
|
||||
await load();
|
||||
if (!editingId.value && items.value[0]) hydrate(items.value[0]);
|
||||
@@ -514,11 +551,23 @@ async function toggleActive(row: AutomationRuleDto, checked: boolean) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('pointerdown', onDocPointerDown, true);
|
||||
document.addEventListener('keydown', onDocKeydown);
|
||||
await load();
|
||||
if (editingId.value) return;
|
||||
if (items.value[0]) hydrate(items.value[0]);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocPointerDown, true);
|
||||
document.removeEventListener('keydown', onDocKeydown);
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
watch(canvasFullscreen, (on) => {
|
||||
document.body.style.overflow = on ? 'hidden' : '';
|
||||
});
|
||||
|
||||
watch(trigger, (next, prev) => {
|
||||
if (prev === 'lead_submitted' && next !== 'lead_submitted') sourceId.value = null;
|
||||
const allowed = allowedConditionFields(next);
|
||||
@@ -703,21 +752,33 @@ watch(trigger, (next, prev) => {
|
||||
message="این قانون ویژگی حرفهای دارد؛ برای ذخیره ارتقا دهید یا شاخه/شرط فرم/نوبت را بردارید."
|
||||
/>
|
||||
|
||||
<a-flex
|
||||
v-if="editingId"
|
||||
:style="{ flex: 1, minHeight: 0 }"
|
||||
:gap="0"
|
||||
>
|
||||
<Teleport to="body" :disabled="!canvasFullscreen">
|
||||
<div
|
||||
:style="{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
v-if="editingId"
|
||||
:style="
|
||||
canvasFullscreen
|
||||
? {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 500,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}"
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
background: token.colorBgLayout,
|
||||
}
|
||||
: {
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}
|
||||
"
|
||||
>
|
||||
<AutomationCanvas
|
||||
v-model:fullscreen="canvasFullscreen"
|
||||
:trigger="trigger"
|
||||
:steps="steps"
|
||||
:selected="selected"
|
||||
@@ -731,29 +792,52 @@ watch(trigger, (next, prev) => {
|
||||
@remove-range="removeRange"
|
||||
@add-branch="addBranch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
v-show="inspectorOpen"
|
||||
<Transition name="auto-inspector">
|
||||
<div
|
||||
v-if="inspectorOpen"
|
||||
ref="inspectorRef"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
insetInlineEnd: `${token.margin}px`,
|
||||
top: `${token.margin}px`,
|
||||
bottom: `${token.margin}px`,
|
||||
width: '320px',
|
||||
flexShrink: 0,
|
||||
minHeight: 0,
|
||||
overflow: 'auto',
|
||||
borderInlineStart: `1px solid ${token.colorBorderSecondary}`,
|
||||
background: token.colorBgContainer,
|
||||
padding: `${token.paddingSM}px`,
|
||||
maxWidth: `calc(100% - ${token.margin * 2}px)`,
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: `${token.marginXS}px`,
|
||||
minHeight: 0,
|
||||
borderRadius: `${token.borderRadiusLG}px`,
|
||||
background: token.colorBgContainer,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
overflow: 'hidden',
|
||||
}"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<a-flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
:style="{
|
||||
flexShrink: 0,
|
||||
padding: `${token.paddingSM}px`,
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
}"
|
||||
>
|
||||
<a-flex justify="space-between" align="center">
|
||||
<a-typography-text strong>{{ inspectorTitle }}</a-typography-text>
|
||||
<a-button type="text" @click="inspectorOpen = false">
|
||||
<template #icon><CloseOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<div
|
||||
:style="{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'auto',
|
||||
padding: `${token.paddingSM}px`,
|
||||
}"
|
||||
>
|
||||
<AutomationInspector
|
||||
v-model:trigger="trigger"
|
||||
v-model:steps="steps"
|
||||
@@ -766,11 +850,14 @@ watch(trigger, (next, prev) => {
|
||||
:teams="teams"
|
||||
:activity-type-defs="activityTypeDefs"
|
||||
/>
|
||||
</aside>
|
||||
</a-flex>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<a-flex
|
||||
v-else
|
||||
v-if="!editingId"
|
||||
vertical
|
||||
align="center"
|
||||
justify="center"
|
||||
@@ -890,4 +977,26 @@ watch(trigger, (next, prev) => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.auto-inspector-enter-active,
|
||||
.auto-inspector-leave-active {
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
transform-origin: top center;
|
||||
will-change: opacity, transform;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.auto-inspector-enter-from,
|
||||
.auto-inspector-leave-to {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 6px, 0) scale(0.97);
|
||||
}
|
||||
|
||||
.auto-inspector-enter-to,
|
||||
.auto-inspector-leave-from {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -351,12 +351,7 @@ onMounted(load);
|
||||
<template>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">چکها</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
دفتر چکهای دریافتی و پرداختی. وضعیت فقط برای پیگیری است
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-space wrap :size="8">
|
||||
<a-button type="primary" @click="openForm('in')">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
|
||||
@@ -114,9 +114,6 @@ onMounted(load);
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">پورسانت فروش</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
گزارش پورسانت ثبتشده برای کارشناسان فروش متصل به فاکتورهای صادره
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-button :disabled="loading" :loading="loading" @click="load">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
|
||||
@@ -38,7 +38,9 @@ import {
|
||||
BankOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
FileExcelOutlined,
|
||||
IdcardOutlined,
|
||||
MailOutlined,
|
||||
MoreOutlined,
|
||||
PhoneOutlined,
|
||||
@@ -637,7 +639,6 @@ function peopleCustomRow(record: PersonDto) {
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">مخاطبین</a-typography-title>
|
||||
<a-typography-text type="secondary">مدیریت و مشاهده اشخاص و شرکتها</a-typography-text>
|
||||
</div>
|
||||
<a-space wrap>
|
||||
<RouterLink v-if="canBehavior" :to="{ name: 'disc-report' }">
|
||||
@@ -827,17 +828,38 @@ function peopleCustomRow(record: PersonDto) {
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="detail">
|
||||
<RouterLink :to="{ name: 'person-detail', params: { id: item.id } }">پرونده</RouterLink>
|
||||
<a-menu-item
|
||||
key="detail"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'person-detail',
|
||||
params: { id: item.id },
|
||||
})
|
||||
"
|
||||
>
|
||||
<template #icon><IdcardOutlined /></template>
|
||||
پرونده
|
||||
</a-menu-item>
|
||||
<a-menu-item key="edit">
|
||||
<RouterLink :to="{ name: 'person-edit', params: { id: item.id } }">ویرایش</RouterLink>
|
||||
<a-menu-item
|
||||
key="edit"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'person-edit',
|
||||
params: { id: item.id },
|
||||
})
|
||||
"
|
||||
>
|
||||
<template #icon><EditOutlined /></template>
|
||||
ویرایش
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
v-for="mood in PERSON_MOODS"
|
||||
:key="mood"
|
||||
@click="setMoodFor(item, mood)"
|
||||
>
|
||||
<template #icon>
|
||||
<CustomerMoodFace :mood="mood" :size="18" />
|
||||
</template>
|
||||
{{ moodLabel(mood) }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
@@ -975,17 +997,38 @@ function peopleCustomRow(record: PersonDto) {
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="detail">
|
||||
<RouterLink :to="{ name: 'person-detail', params: { id: record.id } }">پرونده</RouterLink>
|
||||
<a-menu-item
|
||||
key="detail"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'person-detail',
|
||||
params: { id: record.id },
|
||||
})
|
||||
"
|
||||
>
|
||||
<template #icon><IdcardOutlined /></template>
|
||||
پرونده
|
||||
</a-menu-item>
|
||||
<a-menu-item key="edit">
|
||||
<RouterLink :to="{ name: 'person-edit', params: { id: record.id } }">ویرایش</RouterLink>
|
||||
<a-menu-item
|
||||
key="edit"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'person-edit',
|
||||
params: { id: record.id },
|
||||
})
|
||||
"
|
||||
>
|
||||
<template #icon><EditOutlined /></template>
|
||||
ویرایش
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
v-for="mood in PERSON_MOODS"
|
||||
:key="mood"
|
||||
@click="setMoodFor(record as PersonDto, mood)"
|
||||
>
|
||||
<template #icon>
|
||||
<CustomerMoodFace :mood="mood" :size="18" />
|
||||
</template>
|
||||
{{ moodLabel(mood) }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
|
||||
@@ -4,14 +4,24 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { CURRENCY_OPTIONS, formatMoney } from '@/lib/money';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiField,
|
||||
UiPageHeader,
|
||||
UiSelect,
|
||||
} from '@/components/ui';
|
||||
import type { OrganizationDto, UpdateOrgCurrenciesRequest } from '@mento/shared';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Checkbox as ACheckbox,
|
||||
CheckboxGroup as ACheckboxGroup,
|
||||
Col as ACol,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Spin as ASpin,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
@@ -27,19 +37,29 @@ const rateCurrencies = computed(() =>
|
||||
enabled.value.filter((c) => c !== baseCurrency.value),
|
||||
);
|
||||
|
||||
function toggleCurrency(code: string) {
|
||||
if (code === baseCurrency.value) return;
|
||||
if (enabled.value.includes(code)) {
|
||||
enabled.value = enabled.value.filter((c) => c !== code);
|
||||
} else {
|
||||
enabled.value = [...enabled.value, code];
|
||||
if (!rates.value[code]) rates.value[code] = '';
|
||||
const baseCurrencyOptions = CURRENCY_OPTIONS.map((c) => ({
|
||||
value: c.code,
|
||||
label: `${c.labelFa} (${c.code})`,
|
||||
}));
|
||||
|
||||
function onEnabledChange(values: Array<string | number | boolean>) {
|
||||
const next = values.map(String);
|
||||
if (!next.includes(baseCurrency.value)) {
|
||||
next.unshift(baseCurrency.value);
|
||||
}
|
||||
for (const code of next) {
|
||||
if (code !== baseCurrency.value && rates.value[code] == null) {
|
||||
rates.value[code] = '';
|
||||
}
|
||||
}
|
||||
enabled.value = next;
|
||||
}
|
||||
|
||||
function onBaseChange() {
|
||||
if (!enabled.value.includes(baseCurrency.value)) {
|
||||
enabled.value = [baseCurrency.value, ...enabled.value];
|
||||
function onBaseChange(value: unknown) {
|
||||
const code = String(value ?? '');
|
||||
baseCurrency.value = code;
|
||||
if (!enabled.value.includes(code)) {
|
||||
enabled.value = [code, ...enabled.value];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,201 +128,96 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page currency-page" dir="rtl">
|
||||
<UiPageHeader
|
||||
title="ارزها و نرخ تبدیل"
|
||||
description="ارز پایه برای تجمیع مبالغ و پیشبینیها استفاده میشود. نرخ یعنی چند واحد ارز پایه برابر با ۱ واحد ارز خارجی است."
|
||||
<a-flex vertical :gap="16">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">ارزها و نرخ تبدیل</a-typography-title>
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-card v-if="!loading" :bordered="false">
|
||||
<a-form layout="vertical" @finish="save">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12" :lg="8">
|
||||
<a-form-item
|
||||
label="ارز پایه سیستم"
|
||||
extra="همه فاکتورها، پیشبینیها و آمار کل بر پایه این واحد ارزی محاسبه میشوند."
|
||||
>
|
||||
<a-select
|
||||
:value="baseCurrency"
|
||||
:options="baseCurrencyOptions"
|
||||
style="width: 100%"
|
||||
@update:value="onBaseChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div v-if="error" class="crm-alert crm-alert-danger">{{ error }}</div>
|
||||
<div v-if="ok" class="crm-alert crm-alert-success">{{ ok }}</div>
|
||||
|
||||
<UiCard v-if="!loading" class="section-card">
|
||||
<form class="crm-form" @submit.prevent="save">
|
||||
<UiField label="ارز پایه سیستم" hint="همه فاکتورها، پیشبینیها و آمار کل بر پایه این واحد ارزی محاسبه خواهند شد.">
|
||||
<UiSelect v-model="baseCurrency" @change="onBaseChange">
|
||||
<option
|
||||
<a-form-item label="ارزهای فعال در فاکتورها و معاملات">
|
||||
<a-checkbox-group :value="enabled" @update:value="onEnabledChange">
|
||||
<a-row :gutter="[12, 8]">
|
||||
<a-col
|
||||
v-for="c in CURRENCY_OPTIONS"
|
||||
:key="c.code"
|
||||
:value="c.code"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="8"
|
||||
:lg="6"
|
||||
>
|
||||
{{ c.labelFa }} ({{ c.code }})
|
||||
</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
<a-checkbox :value="c.code" :disabled="c.code === baseCurrency">
|
||||
{{ c.labelFa }}
|
||||
<a-typography-text type="secondary" dir="ltr">
|
||||
({{ c.code }})
|
||||
</a-typography-text>
|
||||
</a-checkbox>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-checkbox-group>
|
||||
</a-form-item>
|
||||
|
||||
<UiField label="ارزهای فعال در فاکتورها و معاملات">
|
||||
<div class="currency-chips">
|
||||
<label
|
||||
v-for="c in CURRENCY_OPTIONS"
|
||||
:key="c.code"
|
||||
class="currency-chip"
|
||||
:class="{ active: enabled.includes(c.code), locked: c.code === baseCurrency }"
|
||||
<a-card v-if="rateCurrencies.length" title="نرخ برابری" :bordered="true">
|
||||
<template #extra>
|
||||
<a-typography-text type="secondary">نسبت به {{ baseCurrency }}</a-typography-text>
|
||||
</template>
|
||||
<a-flex vertical :gap="12">
|
||||
<a-row
|
||||
v-for="code in rateCurrencies"
|
||||
:key="code"
|
||||
:gutter="[12, 8]"
|
||||
align="middle"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="enabled.includes(c.code)"
|
||||
:disabled="c.code === baseCurrency"
|
||||
@change="toggleCurrency(c.code)"
|
||||
/>
|
||||
<span class="chip-title">{{ c.labelFa }}</span>
|
||||
<span class="chip-code font-mono">{{ c.code }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</UiField>
|
||||
|
||||
<div v-if="rateCurrencies.length" class="rates-section">
|
||||
<h3 class="rates-title">نرخ برابری نسبت به {{ baseCurrency }}</h3>
|
||||
<div class="rates-list">
|
||||
<div v-for="code in rateCurrencies" :key="code" class="rate-row">
|
||||
<span class="rate-unit">۱ {{ code }} =</span>
|
||||
<div class="rate-input-wrap">
|
||||
<a-col :xs="24" :sm="6" :md="4">
|
||||
<a-typography-text strong dir="ltr">۱ {{ code }} =</a-typography-text>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="10">
|
||||
<MoneyInput
|
||||
v-model="rates[code]"
|
||||
:decimals="6"
|
||||
:placeholder="`نرخ برابری به ${baseCurrency}`"
|
||||
/>
|
||||
</div>
|
||||
<span class="rate-target">{{ baseCurrency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="rates.USD" class="example-note">
|
||||
نمونه محاسبه: ۱ دلار آمریکا ≈ <strong>{{ formatMoney(Number(rates.USD) || 0, baseCurrency) }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="6" :md="4">
|
||||
<a-typography-text type="secondary" dir="ltr">{{ baseCurrency }}</a-typography-text>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-typography-text v-if="rates.USD" type="secondary">
|
||||
نمونه محاسبه: ۱ دلار آمریکا ≈
|
||||
<a-typography-text strong>
|
||||
{{ formatMoney(Number(rates.USD) || 0, baseCurrency) }}
|
||||
</a-typography-text>
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
<div class="form-submit-row">
|
||||
<UiButton type="submit" :loading="saving">
|
||||
<a-form-item>
|
||||
<a-flex justify="flex-end">
|
||||
<a-button type="primary" html-type="submit" :loading="saving">
|
||||
ذخیره تنظیمات ارز
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</UiCard>
|
||||
</section>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.currency-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
.currency-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.currency-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.currency-chip:hover {
|
||||
border-color: var(--color-brand-400);
|
||||
}
|
||||
|
||||
.currency-chip.active {
|
||||
border-color: var(--color-brand-500);
|
||||
background: var(--color-brand-50, #f0fdfa);
|
||||
color: var(--color-brand-800, #115e59);
|
||||
}
|
||||
|
||||
.currency-chip.locked {
|
||||
opacity: 0.8;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chip-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chip-code {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.rates-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4);
|
||||
background: var(--color-surface-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.rates-title {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rates-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.rate-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.rate-unit {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
.rate-input-wrap {
|
||||
flex: 1;
|
||||
max-width: 14rem;
|
||||
}
|
||||
|
||||
.rate-target {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.example-note {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.example-note strong {
|
||||
color: var(--color-brand-700, #0f766e);
|
||||
}
|
||||
|
||||
.form-submit-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,23 +2,37 @@
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiSelect,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type {
|
||||
CreateCustomFieldRequest,
|
||||
CustomFieldDefinitionDto,
|
||||
CustomFieldEntity,
|
||||
CustomFieldType,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Checkbox as ACheckbox,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
ListItem as AListItem,
|
||||
ListItemMeta as AListItemMeta,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
@@ -47,12 +61,44 @@ const typeLabels: Record<CustomFieldType, string> = {
|
||||
select: 'لیست انتخابی',
|
||||
};
|
||||
|
||||
const entityOptions = [
|
||||
{ value: 'person', label: 'شخص' },
|
||||
{ value: 'company', label: 'شرکت' },
|
||||
{ value: 'deal', label: 'معامله' },
|
||||
{ value: 'note', label: 'یادداشت' },
|
||||
];
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'text', label: 'متن' },
|
||||
{ value: 'number', label: 'عدد' },
|
||||
{ value: 'date', label: 'تاریخ' },
|
||||
{ value: 'boolean', label: 'بله/خیر' },
|
||||
{ value: 'select', label: 'لیست انتخابی' },
|
||||
];
|
||||
|
||||
const filterOptions = [
|
||||
{ value: 'all', label: 'همه موجودیتها' },
|
||||
...entityOptions,
|
||||
];
|
||||
|
||||
const visible = computed(() =>
|
||||
filter.value === 'all'
|
||||
? fields.value
|
||||
: fields.value.filter((f) => f.entityType === filter.value),
|
||||
);
|
||||
|
||||
function entityTagColor(entity: CustomFieldEntity) {
|
||||
if (entity === 'deal') return 'warning';
|
||||
if (entity === 'company') return 'processing';
|
||||
return 'blue';
|
||||
}
|
||||
|
||||
function fieldDescription(field: CustomFieldDefinitionDto) {
|
||||
const parts = [`کلید سیستمی: ${field.key}`];
|
||||
if (field.options?.length) parts.push(`گزینهها: ${field.options.join('، ')}`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -124,258 +170,114 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page narrow">
|
||||
<UiPageHeader
|
||||
title="فیلدهای سفارشی"
|
||||
lead="فیلدهای اختصاصی سازمان را برای شخص، شرکت، معامله یا یادداشت تعریف کنید."
|
||||
<a-flex vertical :gap="16">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">فیلدهای سفارشی</a-typography-title>
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-form layout="vertical" @finish="createField">
|
||||
<a-divider orientation="right">فیلد جدید</a-divider>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="عنوان" required>
|
||||
<a-input v-model:value="label" placeholder="مثلاً صنعت" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="روی چه چیزی؟">
|
||||
<a-select
|
||||
v-model:value="entityType"
|
||||
:options="entityOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
|
||||
<form class="crm-form" @submit.prevent="createField">
|
||||
<UiCard>
|
||||
<template #header>
|
||||
<div class="card-title">
|
||||
<UiIcon name="settings" :size="18" />
|
||||
<h3>فیلد جدید</h3>
|
||||
</div>
|
||||
</template>
|
||||
<div class="crm-form-grid-3">
|
||||
<UiField label="عنوان" required for-id="cf-label">
|
||||
<UiInput id="cf-label" v-model="label" required placeholder="مثلاً صنعت" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="روی چه چیزی؟" for-id="cf-entity">
|
||||
<UiSelect id="cf-entity" v-model="entityType">
|
||||
<option value="person">شخص</option>
|
||||
<option value="company">شرکت</option>
|
||||
<option value="deal">معامله</option>
|
||||
<option value="note">یادداشت</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<UiField label="نوع فیلد" for-id="cf-type">
|
||||
<UiSelect id="cf-type" v-model="fieldType">
|
||||
<option value="text">متن</option>
|
||||
<option value="number">عدد</option>
|
||||
<option value="date">تاریخ</option>
|
||||
<option value="boolean">بله/خیر</option>
|
||||
<option value="select">لیست انتخابی</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
|
||||
<div v-if="fieldType === 'select'" class="crm-col-span-full">
|
||||
<UiField label="گزینهها (با کاما جدا کنید)" for-id="cf-options">
|
||||
<UiInput
|
||||
id="cf-options"
|
||||
v-model="optionsRaw"
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نوع فیلد">
|
||||
<a-select
|
||||
v-model:value="fieldType"
|
||||
:options="typeOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="fieldType === 'select'" :span="24">
|
||||
<a-form-item label="گزینهها (با کاما جدا کنید)">
|
||||
<a-input
|
||||
v-model:value="optionsRaw"
|
||||
placeholder="مثلاً فناوری، تولید، خدمات"
|
||||
/>
|
||||
</UiField>
|
||||
</div>
|
||||
|
||||
<div class="crm-col-span-full check-row">
|
||||
<label class="check">
|
||||
<input v-model="required" type="checkbox" />
|
||||
<span>فیلد اجباری است</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
:loading="saving"
|
||||
>
|
||||
<UiIcon name="plus" :size="16" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item>
|
||||
<a-checkbox v-model:checked="required">فیلد اجباری است</a-checkbox>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-button type="primary" html-type="submit" :loading="saving" :disabled="saving">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
{{ saving ? 'در حال ثبت…' : 'افزودن فیلد' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiCard>
|
||||
</form>
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<div class="crm-toolbar">
|
||||
<UiSelect v-model="filter" class="filter-select">
|
||||
<option value="all">همه موجودیتها</option>
|
||||
<option value="person">شخص</option>
|
||||
<option value="company">شرکت</option>
|
||||
<option value="deal">معامله</option>
|
||||
<option value="note">یادداشت</option>
|
||||
</UiSelect>
|
||||
<UiButton variant="ghost" type="button" @click="load">
|
||||
<UiIcon name="refresh" :size="16" />
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</div>
|
||||
<a-divider orientation="right">فهرست فیلدها</a-divider>
|
||||
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<div v-else class="list">
|
||||
<UiCard v-for="field in visible" :key="field.id" padding="sm" class="field-item-card">
|
||||
<div class="field-item">
|
||||
<div class="field-meta">
|
||||
<div class="field-title-line">
|
||||
<strong>{{ field.label }}</strong>
|
||||
<UiBadge :variant="field.entityType === 'deal' ? 'warning' : field.entityType === 'company' ? 'info' : 'primary'" size="sm">
|
||||
{{ entityLabels[field.entityType] }}
|
||||
</UiBadge>
|
||||
<UiBadge variant="neutral" size="sm">
|
||||
{{ typeLabels[field.fieldType] }}
|
||||
</UiBadge>
|
||||
<UiBadge v-if="field.required" variant="danger" size="sm">
|
||||
اجباری
|
||||
</UiBadge>
|
||||
<UiBadge :variant="field.isActive ? 'success' : 'neutral'" size="sm" dot>
|
||||
{{ field.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
<p class="meta-code">
|
||||
کلید سیستمی: <code>{{ field.key }}</code>
|
||||
<span v-if="field.options?.length" class="options-preview">
|
||||
· گزینهها: {{ field.options.join('، ') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="toggleActive(field)"
|
||||
>
|
||||
{{ field.isActive ? 'غیرفعالسازی' : 'فعالسازی' }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
@click="removeField(field)"
|
||||
>
|
||||
حذف
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiEmptyState
|
||||
v-if="!visible.length"
|
||||
icon="settings"
|
||||
title="فیلدی تعریف نشده است"
|
||||
description="هیچ فیلد سفارشی برای این موجودیت وجود ندارد."
|
||||
<a-row :gutter="[16, 16]" :style="{ marginBottom: '16px' }">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-select
|
||||
v-model:value="filter"
|
||||
:options="filterOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-button @click="load">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بروزرسانی
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-empty
|
||||
v-if="!loading && !visible.length"
|
||||
description="فیلدی تعریف نشده است. هیچ فیلد سفارشی برای این موجودیت وجود ندارد."
|
||||
/>
|
||||
|
||||
<a-list v-else-if="visible.length" item-layout="horizontal" :data-source="visible">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-space wrap>
|
||||
<a-button @click="toggleActive(item)">
|
||||
{{ item.isActive ? 'غیرفعالسازی' : 'فعالسازی' }}
|
||||
</a-button>
|
||||
<a-button danger @click="removeField(item)">حذف</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-list-item-meta :description="fieldDescription(item)">
|
||||
<template #title>
|
||||
<a-space wrap>
|
||||
<a-typography-text strong>{{ item.label }}</a-typography-text>
|
||||
<a-tag :color="entityTagColor(item.entityType)" :bordered="false">
|
||||
{{ entityLabels[item.entityType] }}
|
||||
</a-tag>
|
||||
<a-tag :bordered="false">{{ typeLabels[item.fieldType] }}</a-tag>
|
||||
<a-tag v-if="item.required" color="error" :bordered="false">اجباری</a-tag>
|
||||
<a-tag :color="item.isActive ? 'success' : 'default'" :bordered="false">
|
||||
{{ item.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.card-title h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-base);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.check-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
cursor: pointer;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.field-item-card {
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.field-item-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.field-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.field-meta {
|
||||
display: grid;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.field-title-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.field-title-line strong {
|
||||
font-size: var(--fs-base);
|
||||
color: var(--color-text-emphasis);
|
||||
}
|
||||
|
||||
.meta-code {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.meta-code code {
|
||||
font-family: Consolas, monospace;
|
||||
background: var(--color-surface-sunken);
|
||||
padding: 1px var(--sp-2);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.options-preview {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.field-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,13 +5,38 @@ import axios from 'axios';
|
||||
import type {
|
||||
AuditLogDto,
|
||||
AuditLogListResponse,
|
||||
DeletionArchiveDto,
|
||||
DeletionArchiveListResponse,
|
||||
DeletionRetention,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
|
||||
import { UiButton, UiCard, UiEmptyState, UiModal, UiPageHeader, UiPagination } from '@/components/ui';
|
||||
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
ListItem as AListItem,
|
||||
ListItemMeta as AListItemMeta,
|
||||
Modal as AModal,
|
||||
Pagination as APagination,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tabs as ATabs,
|
||||
TabPane as ATabPane,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
type HistoryTab = 'creates' | 'edits' | 'deletes';
|
||||
|
||||
@@ -102,6 +127,19 @@ function rowNo(index: number) {
|
||||
|
||||
const purgeItem = computed(() => data.value?.items.find((item) => item.id === purgeId.value) ?? null);
|
||||
|
||||
const actorOptions = computed(() => [
|
||||
{ value: '', label: 'همه' },
|
||||
...actors.value.map((user) => ({ value: user.id, label: user.fullName })),
|
||||
]);
|
||||
|
||||
const showDeletesEmpty = computed(
|
||||
() => tab.value === 'deletes' && !error.value && !loading.value && !data.value?.items.length,
|
||||
);
|
||||
|
||||
const showAuditEmpty = computed(
|
||||
() => tab.value !== 'deletes' && !error.value && !loading.value && !audit.value?.items.length,
|
||||
);
|
||||
|
||||
function apiError(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
@@ -352,25 +390,26 @@ async function loadActors() {
|
||||
}
|
||||
}
|
||||
|
||||
function onPage(next: number) {
|
||||
page.value = next;
|
||||
void load();
|
||||
function onPaginationChange(nextPage: number, nextSize: number) {
|
||||
const sizeChanged = nextSize !== pageSize.value;
|
||||
page.value = nextPage;
|
||||
if (sizeChanged) {
|
||||
pageSize.value = nextSize;
|
||||
page.value = 1;
|
||||
}
|
||||
void load(sizeChanged);
|
||||
}
|
||||
|
||||
function onPageSize(size: number) {
|
||||
pageSize.value = size;
|
||||
if (page.value === 1) void load();
|
||||
}
|
||||
|
||||
function selectTab(next: HistoryTab) {
|
||||
if (next === tab.value) return;
|
||||
function selectTab(next: string | number) {
|
||||
const key = String(next) as HistoryTab;
|
||||
if (key === tab.value) return;
|
||||
q.value = '';
|
||||
page.value = 1;
|
||||
data.value = null;
|
||||
audit.value = null;
|
||||
error.value = '';
|
||||
okMsg.value = '';
|
||||
void router.replace({ query: next === 'deletes' ? {} : { tab: next } });
|
||||
void router.replace({ query: key === 'deletes' ? {} : { tab: key } });
|
||||
}
|
||||
|
||||
async function load(resetPage = false) {
|
||||
@@ -450,6 +489,21 @@ async function confirmPurge() {
|
||||
}
|
||||
}
|
||||
|
||||
function deletionDescription(item: DeletionArchiveDto) {
|
||||
return [
|
||||
item.modelLabel,
|
||||
`${item.deletedByName || 'سیستم'} · ${formatJalaliDateTime(new Date(item.deletedAt))}`,
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
function auditDescription(row: AuditLogDto) {
|
||||
const parts: string[] = [];
|
||||
if (pathPhrase(row) !== rowHeading(row)) parts.push(pathPhrase(row));
|
||||
parts.push(row.actor?.fullName || 'سیستم');
|
||||
parts.push(formatJalaliDateTime(new Date(row.createdAt)));
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
watch(tab, () => {
|
||||
void load();
|
||||
}, { immediate: true });
|
||||
@@ -462,319 +516,187 @@ void loadActors();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader
|
||||
title="تاریخچه کارها"
|
||||
lead="هر ایجاد، ویرایش و حذف اینجا ثبت میشود."
|
||||
<a-flex vertical :gap="16">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">تاریخچه کارها</a-typography-title>
|
||||
|
||||
<a-tabs :active-key="tab" @change="selectTab">
|
||||
<a-tab-pane v-for="item in TABS" :key="item.key" :tab="item.label" />
|
||||
</a-tabs>
|
||||
|
||||
<a-alert v-if="okMsg" type="success" show-icon :message="okMsg" closable @close="okMsg = ''" />
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
|
||||
<a-card v-if="tab === 'deletes'" :bordered="false">
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]" align="bottom">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="حذف خودکار از این فهرست">
|
||||
<a-select
|
||||
v-model:value="retention"
|
||||
:options="RETENTION_OPTIONS"
|
||||
style="width: 100%"
|
||||
/>
|
||||
|
||||
<nav class="tabs" aria-label="نوع تاریخچه">
|
||||
<button
|
||||
v-for="item in TABS"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
:class="{ on: tab === item.key }"
|
||||
@click="selectTab(item.key)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<UiCard v-if="tab === 'deletes'">
|
||||
<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">
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" :loading="savingRetention" @click="saveRetention">
|
||||
ذخیره
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
<a-typography-text type="secondary">
|
||||
تا پایان این مدت میتوان مورد را برگرداند. «هرگز» یعنی فقط با حذف کامل از بین میرود.
|
||||
</p>
|
||||
</UiCard>
|
||||
</a-typography-text>
|
||||
</a-card>
|
||||
|
||||
<form class="search" @submit.prevent="load(true)">
|
||||
<input v-model="q" type="search" placeholder="جستجو در عنوان یا نوع" />
|
||||
<label class="actor">
|
||||
<span>انجامدهنده</span>
|
||||
<select v-model="actorId">
|
||||
<option value="">همه</option>
|
||||
<option v-for="user in actors" :key="user.id" :value="user.id">{{ user.fullName }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<UiButton type="submit" variant="secondary" size="sm">جستجو</UiButton>
|
||||
</form>
|
||||
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<UiEmptyState
|
||||
v-else-if="tab === 'deletes' && !error && !data?.items.length"
|
||||
title="حذفی ثبت نشده."
|
||||
description="از این به بعد حذفها با نام کاربر و زمان اینجا دیده میشوند."
|
||||
<a-card :bordered="false">
|
||||
<a-form layout="vertical" @finish="load(true)">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="جستجو">
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
type="search"
|
||||
placeholder="جستجو در عنوان یا نوع"
|
||||
allow-clear
|
||||
/>
|
||||
<UiEmptyState
|
||||
v-else-if="tab !== 'deletes' && !error && !audit?.items.length"
|
||||
:title="tab === 'creates' ? 'ایجاد جدیدی ثبت نشده.' : 'ویرایشی ثبت نشده.'"
|
||||
description="با انجام کار در سامانه، نام کاربر و زمان اینجا دیده میشود. روی مورد بزنید تا خود پرونده باز شود."
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="انجامدهنده">
|
||||
<a-select
|
||||
v-model:value="actorId"
|
||||
:options="actorOptions"
|
||||
style="width: 100%"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="4">
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" html-type="submit" block>جستجو</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<div v-else-if="tab === 'deletes' && data?.items.length" class="list">
|
||||
<article v-for="(item, index) in data.items" :key="item.id" class="row">
|
||||
<span class="idx">{{ toFaDigits(rowNo(index)) }}</span>
|
||||
<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>
|
||||
<a-spin :spinning="loading">
|
||||
<a-card v-if="showDeletesEmpty" :bordered="false">
|
||||
<a-empty description="حذفی ثبت نشده. از این به بعد حذفها با نام کاربر و زمان اینجا دیده میشوند." />
|
||||
</a-card>
|
||||
|
||||
<div v-else-if="audit?.items.length" class="list">
|
||||
<component
|
||||
:is="recordPath(row) ? 'button' : 'div'"
|
||||
v-for="(row, index) in audit.items"
|
||||
:key="row.id"
|
||||
class="row"
|
||||
:class="{ link: !!recordPath(row) }"
|
||||
:type="recordPath(row) ? 'button' : undefined"
|
||||
@click="openRecord(row)"
|
||||
<a-card v-else-if="showAuditEmpty" :bordered="false">
|
||||
<a-empty
|
||||
:description="
|
||||
tab === 'creates'
|
||||
? 'ایجاد جدیدی ثبت نشده. با انجام کار در سامانه، نام کاربر و زمان اینجا دیده میشود.'
|
||||
: 'ویرایشی ثبت نشده. با انجام کار در سامانه، نام کاربر و زمان اینجا دیده میشود.'
|
||||
"
|
||||
/>
|
||||
</a-card>
|
||||
|
||||
<a-card v-else-if="tab === 'deletes' && data?.items.length" :bordered="false">
|
||||
<a-list item-layout="horizontal" :data-source="data.items">
|
||||
<template #renderItem="{ item, index }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-space wrap>
|
||||
<a-button
|
||||
:disabled="busyId === item.id"
|
||||
@click="restore(item.id)"
|
||||
>
|
||||
<span class="idx">{{ toFaDigits(rowNo(index)) }}</span>
|
||||
<div class="info">
|
||||
<strong>{{ rowHeading(row) }}</strong>
|
||||
<span v-if="rowDetail(row)" class="detail">{{ rowDetail(row) }}</span>
|
||||
<span>
|
||||
<template v-if="pathPhrase(row) !== rowHeading(row)">{{ pathPhrase(row) }} · </template>
|
||||
{{ row.actor?.fullName || 'سیستم' }}
|
||||
·
|
||||
{{ formatJalaliDateTime(new Date(row.createdAt)) }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="recordPath(row)" class="open">مشاهده</span>
|
||||
</component>
|
||||
</div>
|
||||
بازگردانی
|
||||
</a-button>
|
||||
<a-button
|
||||
danger
|
||||
:disabled="busyId === item.id"
|
||||
@click="purgeId = item.id"
|
||||
>
|
||||
حذف کامل
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-list-item-meta :description="deletionDescription(item)">
|
||||
<template #avatar>
|
||||
<a-typography-text type="secondary">{{ toFaDigits(rowNo(index)) }}</a-typography-text>
|
||||
</template>
|
||||
<template #title>
|
||||
<a-typography-text strong>{{ item.label }}</a-typography-text>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
|
||||
<UiPagination
|
||||
<a-card v-else-if="audit?.items.length" :bordered="false">
|
||||
<a-list item-layout="horizontal" :data-source="audit.items">
|
||||
<template #renderItem="{ item, index }">
|
||||
<a-list-item
|
||||
:style="recordPath(item) ? { cursor: 'pointer' } : undefined"
|
||||
@click="openRecord(item)"
|
||||
>
|
||||
<template v-if="recordPath(item)" #actions>
|
||||
<a-button type="link" @click.stop="openRecord(item)">مشاهده</a-button>
|
||||
</template>
|
||||
<a-list-item-meta :description="auditDescription(item)">
|
||||
<template #avatar>
|
||||
<a-typography-text type="secondary">{{ toFaDigits(rowNo(index)) }}</a-typography-text>
|
||||
</template>
|
||||
<template #title>
|
||||
<a-flex vertical :gap="2">
|
||||
<a-typography-text strong>{{ rowHeading(item) }}</a-typography-text>
|
||||
<a-typography-text v-if="rowDetail(item)" type="secondary">
|
||||
{{ rowDetail(item) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
|
||||
<a-flex
|
||||
v-if="listSource"
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
:total-items="listSource.total"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
:gap="8"
|
||||
>
|
||||
<a-pagination
|
||||
:current="page"
|
||||
:total="listSource.total"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="PAGE_SIZE_OPTIONS"
|
||||
@update:page="onPage"
|
||||
@update:page-size="onPageSize"
|
||||
:show-size-changer="true"
|
||||
:page-size-options="PAGE_SIZE_OPTIONS.map(String)"
|
||||
@change="onPaginationChange"
|
||||
/>
|
||||
<a-typography-text type="secondary">
|
||||
صفحه {{ toFaDigits(page) }} از {{ toFaDigits(totalPages) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
|
||||
<UiModal :open="!!purgeItem" title="حذف کامل" @close="purgeId = null">
|
||||
<p v-if="purgeItem" class="confirm">
|
||||
<a-modal
|
||||
:open="!!purgeItem"
|
||||
title="حذف کامل"
|
||||
ok-text="حذف کامل"
|
||||
cancel-text="انصراف"
|
||||
ok-type="danger"
|
||||
:confirm-loading="!!busyId"
|
||||
destroy-on-close
|
||||
@ok="confirmPurge"
|
||||
@cancel="purgeId = null"
|
||||
>
|
||||
<a-typography-text v-if="purgeItem">
|
||||
«{{ purgeItem.label }}» برای همیشه پاک شود؟ بعد از این دیگر قابل بازگشت نیست.
|
||||
</p>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" :disabled="!!busyId" @click="purgeId = null">انصراف</UiButton>
|
||||
<UiButton variant="danger" :loading="!!busyId" @click="confirmPurge">حذف کامل</UiButton>
|
||||
</a-typography-text>
|
||||
</a-modal>
|
||||
</a-flex>
|
||||
</template>
|
||||
</UiModal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.55rem 0.2rem 0.7rem;
|
||||
color: #64748b;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 -2px 0 transparent;
|
||||
}
|
||||
|
||||
.tabs button.on {
|
||||
color: #0f766e;
|
||||
box-shadow: inset 0 -2px 0 #14b8a6;
|
||||
}
|
||||
|
||||
.retention {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.retention label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.retention select,
|
||||
.search input,
|
||||
.search select {
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.actor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.idx {
|
||||
flex: 0 0 auto;
|
||||
min-width: 1.5rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
|
||||
.search input {
|
||||
flex: 1 1 12rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e7eef2;
|
||||
border-radius: 14px;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.row.link {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row.link:hover {
|
||||
border-color: #99f6e4;
|
||||
}
|
||||
|
||||
.open {
|
||||
flex: 0 0 auto;
|
||||
margin-inline-start: auto;
|
||||
align-self: center;
|
||||
color: #0f766e;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
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;
|
||||
}
|
||||
|
||||
.info span.detail {
|
||||
color: #334155;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
}
|
||||
|
||||
.ops {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-inline-start: auto;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -8,16 +8,28 @@ import type {
|
||||
UpdateFaqArticleRequest,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Checkbox as ACheckbox,
|
||||
Col as ACol,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
InputNumber as AInputNumber,
|
||||
Row as ARow,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
@@ -127,204 +139,107 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page faq-page" dir="rtl">
|
||||
<UiPageHeader
|
||||
title="پایگاه دانش و سؤالات متداول"
|
||||
description="سؤالات پرتکرار و پاسخهای جامع برای تسریع در پاسخگویی پشتیبانان و کاهش تیکتهای مکرر"
|
||||
<a-flex vertical :gap="16">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">
|
||||
پایگاه دانش و سؤالات متداول
|
||||
</a-typography-title>
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
|
||||
|
||||
<a-card :title="editingId ? 'ویرایش مقاله' : 'مقاله جدید'" :bordered="false">
|
||||
<a-form layout="vertical" @finish="save">
|
||||
<a-form-item label="پرسش / عنوان مقاله" required>
|
||||
<a-input
|
||||
v-model:value="question"
|
||||
:maxlength="500"
|
||||
placeholder="مثلاً نحوه فعالسازی حساب کاربری..."
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<div v-if="error" class="crm-alert crm-alert-danger">{{ error }}</div>
|
||||
<div v-if="ok" class="crm-alert crm-alert-success">{{ ok }}</div>
|
||||
<a-form-item label="پاسخ کامل" required>
|
||||
<a-textarea
|
||||
v-model:value="answer"
|
||||
:rows="4"
|
||||
placeholder="توضیحات و راهنمای گامبهگام..."
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<UiCard :title="editingId ? 'ویرایش مقاله' : 'مقاله جدید'" class="editor-card">
|
||||
<form class="crm-form" @submit.prevent="save">
|
||||
<UiField label="پرسش / عنوان مقاله" required>
|
||||
<UiInput v-model="question" required maxlength="500" placeholder="مثلاً نحوه فعالسازی حساب کاربری..." />
|
||||
</UiField>
|
||||
<a-row :gutter="[16, 0]" align="bottom">
|
||||
<a-col :xs="24" :md="10">
|
||||
<a-form-item label="دستهبندی">
|
||||
<a-input
|
||||
v-model:value="category"
|
||||
:maxlength="100"
|
||||
placeholder="مثلاً مالی، فنی، نصب"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="6">
|
||||
<a-form-item label="ترتیب نمایش">
|
||||
<a-input-number v-model:value="position" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label=" ">
|
||||
<a-checkbox v-model:checked="isPublished">منتشرشده در پورتال</a-checkbox>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<UiField label="پاسخ کامل" required>
|
||||
<UiTextarea v-model="answer" :rows="4" required placeholder="توضیحات و راهنمای گامبهگام..." />
|
||||
</UiField>
|
||||
|
||||
<div class="form-grid-extra">
|
||||
<UiField label="دستهبندی">
|
||||
<UiInput v-model="category" maxlength="100" placeholder="مثلاً مالی، فنی، نصب" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="ترتیب نمایش">
|
||||
<UiInput v-model.number="position" type="number" min="0" />
|
||||
</UiField>
|
||||
|
||||
<div class="publish-toggle-wrap">
|
||||
<label class="publish-label">
|
||||
<input v-model="isPublished" type="checkbox" />
|
||||
<span>منتشرشده در پورتال</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions-row">
|
||||
<UiButton type="submit" :loading="saving">
|
||||
<a-form-item>
|
||||
<a-space wrap>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">
|
||||
{{ editingId ? 'ذخیره تغییرات' : 'افزودن مقاله' }}
|
||||
</UiButton>
|
||||
<UiButton v-if="editingId" variant="ghost" type="button" @click="resetForm">
|
||||
انصراف
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</UiCard>
|
||||
</a-button>
|
||||
<a-button v-if="editingId" @click="resetForm">انصراف</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<UiEmptyState
|
||||
<a-spin :spinning="loading">
|
||||
<a-empty
|
||||
v-if="!loading && !items.length"
|
||||
title="هنوز مقالهای ثبت نشده است"
|
||||
description="با ثبت اولین سؤال و پاسخ، پایگاه دانش سازمان را تکمیل کنید."
|
||||
icon="document"
|
||||
description="هنوز مقالهای ثبت نشده است. با ثبت اولین سؤال و پاسخ، پایگاه دانش را تکمیل کنید."
|
||||
/>
|
||||
|
||||
<div v-else class="articles-list">
|
||||
<UiCard v-for="row in items" :key="row.id" class="article-card">
|
||||
<div class="article-content">
|
||||
<div class="article-title-row">
|
||||
<strong class="article-question">{{ row.question }}</strong>
|
||||
<div class="article-badges">
|
||||
<UiBadge :variant="row.isPublished ? 'success' : 'neutral'">
|
||||
<a-flex v-else vertical :gap="12">
|
||||
<a-card v-for="row in items" :key="row.id" :bordered="false">
|
||||
<a-row :gutter="[16, 12]" align="top">
|
||||
<a-col :xs="24" :lg="18">
|
||||
<a-flex vertical :gap="8">
|
||||
<a-space wrap>
|
||||
<a-typography-text strong>{{ row.question }}</a-typography-text>
|
||||
<a-tag :color="row.isPublished ? 'success' : 'default'" :bordered="false">
|
||||
{{ row.isPublished ? 'منتشرشده' : 'پیشنویس' }}
|
||||
</UiBadge>
|
||||
<UiBadge v-if="row.category" variant="info">
|
||||
</a-tag>
|
||||
<a-tag v-if="row.category" color="processing" :bordered="false">
|
||||
{{ row.category }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
</div>
|
||||
<p class="article-answer">{{ row.answer }}</p>
|
||||
</div>
|
||||
|
||||
<div class="article-actions">
|
||||
<UiButton variant="secondary" size="sm" @click="startEdit(row)">
|
||||
<template #icon>
|
||||
<UiIcon name="pencil" />
|
||||
</template>
|
||||
</a-tag>
|
||||
</a-space>
|
||||
<a-typography-paragraph type="secondary" :style="{ marginBottom: 0, whiteSpace: 'pre-wrap' }">
|
||||
{{ row.answer }}
|
||||
</a-typography-paragraph>
|
||||
</a-flex>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="6">
|
||||
<a-space wrap>
|
||||
<a-button @click="startEdit(row)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
ویرایش
|
||||
</UiButton>
|
||||
<UiButton variant="danger" size="sm" @click="remove(row)">
|
||||
<template #icon>
|
||||
<UiIcon name="trash" />
|
||||
</template>
|
||||
</a-button>
|
||||
<a-button danger @click="remove(row)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
حذف
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
</section>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</a-spin>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.faq-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.editor-card {
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
.form-grid-extra {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: var(--sp-4);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.publish-toggle-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--control-h, 40px);
|
||||
}
|
||||
|
||||
.publish-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.form-actions-row {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.articles-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.article-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
}
|
||||
|
||||
.article-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.article-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.article-question {
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.article-badges {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.article-answer {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--lh-relaxed);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.article-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.form-grid-extra {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.article-card {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.article-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -216,13 +216,6 @@ const pageTitle = computed(() => {
|
||||
return 'افزودن فاکتور';
|
||||
});
|
||||
|
||||
const pageSubtitle = computed(() => {
|
||||
if (mobileStep.value === 2) return 'محصولات و خدمات را اضافه کنید';
|
||||
if (docMode.value === 'quote') return 'اطلاعات پیش فاکتور را وارد کنید';
|
||||
if (docMode.value === 'credit') return 'اطلاعات فاکتور برگشتی را وارد کنید';
|
||||
return 'اطلاعات فاکتور را وارد کنید';
|
||||
});
|
||||
|
||||
const defaultTitle = computed(() => {
|
||||
if (docMode.value === 'quote') return 'پیش فاکتور';
|
||||
if (docMode.value === 'credit') return 'فاکتور برگشتی';
|
||||
@@ -1032,7 +1025,6 @@ onMounted(load);
|
||||
<FileTextOutlined />
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">{{ pageTitle }}</a-typography-title>
|
||||
<a-typography-text type="secondary">{{ pageSubtitle }}</a-typography-text>
|
||||
</div>
|
||||
</a-space>
|
||||
<a-button @click="goBack">
|
||||
|
||||
@@ -556,9 +556,6 @@ defineExpose({ toggleSelect, pageCount, statusIcon });
|
||||
>
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">فاکتور ها</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
مدیریت فاکتورهای فروش، پیش فاکتور و خدمات
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-space :size="8" @click.stop>
|
||||
<a-dropdown v-model:open="addOpen">
|
||||
|
||||
@@ -542,9 +542,6 @@ onMounted(load);
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">
|
||||
مسیر لیدها
|
||||
</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
کشیدن کارت برای تغییر وضعیت. از منوی هر ستون میتوانید نام، ترتیب و حذف را عوض کنید.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-space :size="GAP">
|
||||
<RouterLink :to="{ name: 'leads' }" custom v-slot="{ navigate }">
|
||||
|
||||
@@ -505,9 +505,6 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">
|
||||
{{ lead?.title || 'جزئیات لید' }}
|
||||
</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
{{ lead ? `موبایل ${lead.mobile}` : 'در حال بارگذاری…' }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</a-space>
|
||||
<a-space v-if="lead" :size="8" wrap>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,16 +3,31 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterLink, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiPageHeader,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type { LeadFormDto } from '@mento/shared';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
ListItem as AListItem,
|
||||
ListItemMeta as AListItemMeta,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import {
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
EyeOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
@@ -78,201 +93,97 @@ async function copyPublishLink(form: LeadFormDto) {
|
||||
await navigator.clipboard.writeText(publicUrl(form));
|
||||
}
|
||||
|
||||
function formMeta(form: LeadFormDto) {
|
||||
const parts = [
|
||||
`${toFaDigits(form.fields.length)} فیلد سفارشی`,
|
||||
`${toFaDigits(form.submitCount ?? form.submissionCount ?? 0)} ارسال`,
|
||||
`${toFaDigits(form.leadCreateCount ?? 0)} لید`,
|
||||
];
|
||||
if (form.source) parts.push(`منبع: ${form.source}`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
const countLabel = computed(() => toFaDigits(forms.value.length));
|
||||
const listEmpty = computed(() => !loading.value && !forms.value.length);
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page lead-forms-page" dir="rtl">
|
||||
<UiPageHeader
|
||||
title="فرمساز وب"
|
||||
description="فرمهای آنلاین بسازید، در وبسایت منتشر کنید و لیدها را مستقیم در CRM دریافت کنید."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton :loading="saving" @click="createForm">
|
||||
<template #icon>
|
||||
<UiIcon name="plus" />
|
||||
</template>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">فرمساز وب</a-typography-title>
|
||||
<a-button type="primary" :loading="saving" @click="createForm">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
فرم جدید
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<div v-if="error" class="crm-alert crm-alert-danger">{{ error }}</div>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<div v-else-if="!loading && forms.length" class="forms-summary">
|
||||
<span class="forms-count-badge">{{ countLabel }} فرم ساخته شده</span>
|
||||
</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-if="!loading && !forms.length"
|
||||
title="هنوز فرمی نساختهاید"
|
||||
description="با ساخت اولین فرم، میتوانید در وبسایت یا شبکههای اجتماعی سرنخ و مشتری جذب کنید."
|
||||
icon="document"
|
||||
<a-spin :spinning="loading">
|
||||
<a-card v-if="listEmpty && !error" :bordered="false">
|
||||
<a-empty
|
||||
description="هنوز فرمی نساختهاید. با ساخت اولین فرم، میتوانید در وبسایت یا شبکههای اجتماعی سرنخ و مشتری جذب کنید."
|
||||
>
|
||||
<template #action>
|
||||
<UiButton :loading="saving" @click="createForm">
|
||||
<template #icon>
|
||||
<UiIcon name="plus" />
|
||||
</template>
|
||||
<a-button type="primary" :loading="saving" @click="createForm">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
ساخت اولین فرم
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiEmptyState>
|
||||
</a-button>
|
||||
</a-empty>
|
||||
</a-card>
|
||||
|
||||
<div v-else class="forms-list">
|
||||
<UiCard v-for="form in forms" :key="form.id" class="form-card">
|
||||
<div class="form-info">
|
||||
<div class="form-title-row">
|
||||
<strong class="form-name">{{ form.name }}</strong>
|
||||
<UiBadge :variant="form.isActive ? 'success' : 'neutral'">
|
||||
{{ form.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
<p class="form-meta">
|
||||
<span>{{ toFaDigits(form.fields.length) }} فیلد سفارشی</span>
|
||||
<span class="meta-dot">·</span>
|
||||
<span>{{ toFaDigits(form.submitCount ?? form.submissionCount ?? 0) }} ارسال</span>
|
||||
<span class="meta-dot">·</span>
|
||||
<span>{{ toFaDigits(form.leadCreateCount ?? 0) }} لید</span>
|
||||
<span v-if="form.source" class="meta-dot">·</span>
|
||||
<span v-if="form.source">منبع: {{ form.source }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<a-card v-else-if="!error && forms.length" :bordered="false">
|
||||
<a-flex vertical :gap="12">
|
||||
<a-typography-text type="secondary">
|
||||
{{ countLabel }} فرم ساخته شده
|
||||
</a-typography-text>
|
||||
|
||||
<div class="form-actions">
|
||||
<RouterLink :to="{ name: 'lead-form-builder', params: { id: form.id } }">
|
||||
<UiButton variant="secondary" size="sm">
|
||||
<template #icon>
|
||||
<UiIcon name="pencil" />
|
||||
</template>
|
||||
<a-list item-layout="horizontal" :data-source="forms">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-space wrap>
|
||||
<RouterLink
|
||||
:to="{ name: 'lead-form-builder', params: { id: item.id } }"
|
||||
custom
|
||||
v-slot="{ href, navigate }"
|
||||
>
|
||||
<a-button :href="href" @click="navigate">
|
||||
<template #icon><EditOutlined /></template>
|
||||
ویرایش فیلدها
|
||||
</UiButton>
|
||||
</a-button>
|
||||
</RouterLink>
|
||||
|
||||
<UiButton variant="secondary" size="sm" @click="copyPublishLink(form)">
|
||||
<template #icon>
|
||||
<UiIcon name="megaphone" />
|
||||
</template>
|
||||
<a-button @click="copyPublishLink(item)">
|
||||
<template #icon><CopyOutlined /></template>
|
||||
کپی لینک انتشار
|
||||
</UiButton>
|
||||
|
||||
<a :href="form.publicPath" target="_blank" rel="noopener" class="preview-link">
|
||||
<UiButton variant="ghost" size="sm">
|
||||
<template #icon>
|
||||
<UiIcon name="eye" />
|
||||
</template>
|
||||
</a-button>
|
||||
<a-button :href="item.publicPath" target="_blank" rel="noopener">
|
||||
<template #icon><EyeOutlined /></template>
|
||||
پیشنمایش
|
||||
</UiButton>
|
||||
</a>
|
||||
|
||||
<UiButton variant="danger" size="sm" @click="removeForm(form)">
|
||||
<template #icon>
|
||||
<UiIcon name="trash" />
|
||||
</template>
|
||||
</a-button>
|
||||
<a-button danger @click="removeForm(item)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
حذف
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
</section>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lead-forms-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.forms-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.forms-count-badge {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-soft);
|
||||
padding: var(--sp-1) var(--sp-3);
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.forms-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.form-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.form-card:hover {
|
||||
border-color: var(--color-brand-300, #5eead4);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.form-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.form-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.form-name {
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-meta {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.meta-dot {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preview-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.form-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<a-list-item-meta :description="formMeta(item)">
|
||||
<template #title>
|
||||
<a-space :size="8" align="center" wrap>
|
||||
<a-typography-text strong>{{ item.name }}</a-typography-text>
|
||||
<a-tag :color="item.isActive ? 'success' : 'default'" :bordered="false">
|
||||
{{ item.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -1186,9 +1186,6 @@ onBeforeUnmount(() => {
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="token.marginSM">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">لیدها</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
مدیریت و پیگیری تمام لیدهای ورودی
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-space :size="8" wrap>
|
||||
<a-button :disabled="busy" :loading="pageBusy && busyMessage.includes('خروجی')" @click.stop="exportExcel">
|
||||
|
||||
@@ -727,13 +727,10 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">
|
||||
<CrownOutlined :style="{ color: 'var(--ant-color-warning)' }" />
|
||||
باشگاه مشتریان
|
||||
</a-typography-title>
|
||||
<a-typography-text type="secondary">وفاداری، یک رابطه طولانیمدت است.</a-typography-text>
|
||||
</a-flex>
|
||||
<a-button @click="openSettings">
|
||||
<template #icon><SettingOutlined /></template>
|
||||
تنظیمات باشگاه
|
||||
@@ -782,9 +779,6 @@ onBeforeUnmount(() => {
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8" :style="{ marginBottom: '16px' }">
|
||||
<a-flex vertical :gap="2">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">سطوح باشگاه</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
مرتبشده از کمترین تا بیشترین آستانه امتیاز
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-button v-if="canManage" type="primary" @click="openNewTier">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
|
||||
@@ -451,12 +451,7 @@ onUnmounted(() => {
|
||||
<a-flex vertical :gap="16">
|
||||
<!-- Mobile list -->
|
||||
<template v-if="isMobile && mobileView === 'list'">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">اتوماسیون پیامکها</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
با قوانین هوشمند، در زمان مناسب پیام مناسب را ارسال کنید.
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-flex vertical align="center" :gap="12">
|
||||
@@ -807,12 +802,7 @@ onUnmounted(() => {
|
||||
<!-- Desktop -->
|
||||
<template v-else>
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">اتوماسیون پیامکها</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
پیامک و ناتیف بعد از رویداد، با تأخیر یا همان لحظه.
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-button :disabled="loading" @click="load">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بروزرسانی
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type {
|
||||
@@ -10,16 +11,32 @@ import type {
|
||||
MontifyTestResultDto,
|
||||
UpdateMontifyConnectionRequest,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
InputPassword as AInputPassword,
|
||||
List as AList,
|
||||
ListItem as AListItem,
|
||||
Row as ARow,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
@@ -41,6 +58,16 @@ const form = reactive(emptyForm());
|
||||
|
||||
const anyRunning = computed(() => items.value.some((c) => c.syncStatus === 'running'));
|
||||
|
||||
const orderColumns: TableColumnsType<MontifyOrderDto> = [
|
||||
{ title: 'فروشگاه', key: 'فروشگاه' },
|
||||
{ title: 'کد سفارش', key: 'کد سفارش' },
|
||||
{ title: 'مشتری', key: 'مشتری' },
|
||||
{ title: 'وضعیت سفارش', key: 'وضعیت سفارش' },
|
||||
{ title: 'وضعیت پرداخت', key: 'وضعیت پرداخت' },
|
||||
{ title: 'مبلغ (ریال)', key: 'مبلغ (ریال)' },
|
||||
{ title: 'تاریخ', key: 'تاریخ' },
|
||||
];
|
||||
|
||||
function errMsg(e: unknown, fallback: string): string {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const m = (e.response?.data as { message?: string | string[] })?.message;
|
||||
@@ -227,11 +254,11 @@ function statusLabel(c: MontifyConnectionDto): string {
|
||||
return 'هنوز همگام نشده';
|
||||
}
|
||||
|
||||
function statusClass(c: MontifyConnectionDto): string {
|
||||
if (!c.enabled) return 'off';
|
||||
if (c.syncStatus === 'running') return 'run';
|
||||
if (c.syncStatus === 'error' || c.lastHealthOk === false) return 'bad';
|
||||
return 'good';
|
||||
function statusColor(c: MontifyConnectionDto): string {
|
||||
if (!c.enabled) return 'default';
|
||||
if (c.syncStatus === 'running') return 'processing';
|
||||
if (c.syncStatus === 'error' || c.lastHealthOk === false) return 'error';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
onMounted(() => void load());
|
||||
@@ -241,70 +268,107 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<UiPageHeader
|
||||
title="فروشگاههای مونتیفای"
|
||||
description="اتصال فقطخواندنی چند فروشگاه؛ مشتریها به اشخاص وصل میشوند و سفارش، آدرس، تراکنش و تعاملات هر مشتری در پروندهاش دیده میشود"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" :disabled="loading" @click="load()">
|
||||
<UiIcon name="refresh" />
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">
|
||||
فروشگاههای مونتیفای
|
||||
</a-typography-title>
|
||||
<a-button :disabled="loading" @click="load()">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
|
||||
|
||||
<UiCard :title="editingId ? 'ویرایش فروشگاه' : 'افزودن فروشگاه'">
|
||||
<p class="hint">
|
||||
فروشنده در پنل مونتیفای از «تنظیمات ← یکپارچهسازیها ← CRM» توکن صادر میکند و
|
||||
Base URL، Token و Store ID را به شما میدهد. توکن فقط روی سرور و بهصورت رمزشده نگهداری میشود.
|
||||
</p>
|
||||
<div class="form-grid">
|
||||
<UiField label="نام فروشگاه">
|
||||
<UiInput v-model="form.label" placeholder="مثلاً فروشگاه شعبه ۱" />
|
||||
</UiField>
|
||||
<UiField label="Base URL">
|
||||
<UiInput v-model="form.baseUrl" dir="ltr" placeholder="https://store1.montify.com/api/v1" />
|
||||
</UiField>
|
||||
<UiField :label="editingId ? 'توکن جدید (خالی = بدون تغییر)' : 'Token'">
|
||||
<UiInput v-model="form.token" type="password" dir="ltr" placeholder="mk_live_…" />
|
||||
</UiField>
|
||||
<UiField label="Store ID">
|
||||
<UiInput v-model="form.storeId" dir="ltr" placeholder="b2f1e4a0-…" />
|
||||
</UiField>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<UiButton :loading="saving" @click="save">
|
||||
{{ editingId ? 'ذخیره تغییرات' : 'افزودن فروشگاه' }}
|
||||
</UiButton>
|
||||
<UiButton v-if="editingId" variant="secondary" @click="resetForm">انصراف</UiButton>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="فروشگاههای متصل">
|
||||
<UiEmptyState
|
||||
v-if="!loading && !items.length"
|
||||
title="فروشگاهی وصل نشده"
|
||||
description="اطلاعات اتصال هر فروشگاه را در فرم بالا وارد کنید."
|
||||
<a-spin :spinning="loading">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-card :title="editingId ? 'ویرایش فروشگاه' : 'افزودن فروشگاه'" :bordered="false">
|
||||
<a-typography-paragraph type="secondary">
|
||||
فروشنده در پنل مونتیفای از «تنظیمات ← یکپارچهسازیها ← CRM» توکن صادر میکند و Base URL،
|
||||
Token و Store ID را به شما میدهد. توکن فقط روی سرور و بهصورت رمزشده نگهداری میشود.
|
||||
</a-typography-paragraph>
|
||||
<a-form layout="vertical" @finish="save">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام فروشگاه" required>
|
||||
<a-input
|
||||
v-model:value="form.label"
|
||||
placeholder="مثلاً فروشگاه شعبه ۱"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div v-else class="list">
|
||||
<article v-for="c in items" :key="c.id" class="item">
|
||||
<div class="info">
|
||||
<div class="title-row">
|
||||
<strong>{{ c.label }}</strong>
|
||||
<span class="badge" :class="statusClass(c)">{{ statusLabel(c) }}</span>
|
||||
</div>
|
||||
<p class="muted ltr">{{ c.baseUrl }}</p>
|
||||
<p class="muted">
|
||||
توکن {{ c.tokenMasked }}
|
||||
<template v-if="c.storeId"> · Store ID {{ c.storeId }}</template>
|
||||
· {{ toFaDigits(c.linkedCustomers) }} مشتری
|
||||
· {{ toFaDigits(c.orders) }} سفارش
|
||||
</p>
|
||||
<p class="muted">
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="Base URL" required>
|
||||
<a-input
|
||||
v-model:value="form.baseUrl"
|
||||
dir="ltr"
|
||||
placeholder="https://store1.montify.com/api/v1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
:label="editingId ? 'توکن جدید (خالی = بدون تغییر)' : 'Token'"
|
||||
:required="!editingId"
|
||||
>
|
||||
<a-input-password
|
||||
v-model:value="form.token"
|
||||
dir="ltr"
|
||||
placeholder="mk_live_…"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="Store ID">
|
||||
<a-input
|
||||
v-model:value="form.storeId"
|
||||
dir="ltr"
|
||||
placeholder="b2f1e4a0-…"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item>
|
||||
<a-space wrap>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">
|
||||
{{ editingId ? 'ذخیره تغییرات' : 'افزودن فروشگاه' }}
|
||||
</a-button>
|
||||
<a-button v-if="editingId" @click="resetForm">انصراف</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card title="فروشگاههای متصل" :bordered="false">
|
||||
<a-empty
|
||||
v-if="!loading && !items.length"
|
||||
description="فروشگاهی وصل نشده. اطلاعات اتصال هر فروشگاه را در فرم بالا وارد کنید."
|
||||
/>
|
||||
<a-flex v-else vertical :gap="12">
|
||||
<a-card v-for="c in items" :key="c.id" type="inner">
|
||||
<a-row :gutter="[16, 12]" align="top">
|
||||
<a-col :xs="24" :lg="14">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-space wrap>
|
||||
<a-typography-text strong>{{ c.label }}</a-typography-text>
|
||||
<a-tag :color="statusColor(c)" :bordered="false">{{ statusLabel(c) }}</a-tag>
|
||||
</a-space>
|
||||
<a-typography-text type="secondary" dir="ltr">{{ c.baseUrl }}</a-typography-text>
|
||||
<a-typography-text type="secondary">
|
||||
توکن
|
||||
<span dir="ltr">{{ c.tokenMasked }}</span>
|
||||
<template v-if="c.storeId">
|
||||
· Store ID <span dir="ltr">{{ c.storeId }}</span>
|
||||
</template>
|
||||
· {{ toFaDigits(c.linkedCustomers) }} مشتری · {{ toFaDigits(c.orders) }} سفارش
|
||||
</a-typography-text>
|
||||
<a-typography-text type="secondary">
|
||||
{{
|
||||
c.lastSyncAt
|
||||
? `آخرین همگامسازی ${formatJalaliNumeric(new Date(c.lastSyncAt))}`
|
||||
@@ -314,259 +378,141 @@ onBeforeUnmount(() => {
|
||||
<template v-if="c.lastWebhookAt">
|
||||
(آخرین رویداد {{ formatJalaliNumeric(new Date(c.lastWebhookAt)) }})
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="c.lastSyncResult" class="muted">
|
||||
</a-typography-text>
|
||||
<a-typography-text v-if="c.lastSyncResult" type="secondary">
|
||||
{{ c.syncStatus === 'running' ? 'پیشرفت' : 'نتیجه' }}:
|
||||
{{ toFaDigits(c.lastSyncResult.customersFetched) }} مشتری خوانده شد
|
||||
({{ toFaDigits(c.lastSyncResult.customersCreated) }} شخص جدید)،
|
||||
{{ toFaDigits(c.lastSyncResult.ordersFetched) }} سفارش،
|
||||
{{ toFaDigits(c.lastSyncResult.customersFetched) }} مشتری خوانده شد ({{
|
||||
toFaDigits(c.lastSyncResult.customersCreated)
|
||||
}}
|
||||
شخص جدید)، {{ toFaDigits(c.lastSyncResult.ordersFetched) }} سفارش،
|
||||
{{ toFaDigits(c.lastSyncResult.transactionsFetched) }} تراکنش
|
||||
<template v-if="c.lastSyncResult.errors.length">
|
||||
· {{ toFaDigits(c.lastSyncResult.errors.length) }} خطا
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
|
||||
<div v-if="diagnoses[c.id]" class="diag">
|
||||
<p class="muted">
|
||||
بررسی مسیرها
|
||||
<template v-if="diagnoses[c.id]!.customerId">
|
||||
(با مشتری {{ diagnoses[c.id]!.customerId }})
|
||||
</a-typography-text>
|
||||
<a-alert
|
||||
v-if="c.lastError"
|
||||
type="warning"
|
||||
show-icon
|
||||
:message="c.lastError"
|
||||
/>
|
||||
<a-card v-if="diagnoses[c.id]" type="inner" title="بررسی مسیرها">
|
||||
<a-typography-text
|
||||
v-if="diagnoses[c.id]!.customerId"
|
||||
type="secondary"
|
||||
>
|
||||
با مشتری {{ diagnoses[c.id]!.customerId }}
|
||||
</a-typography-text>
|
||||
<a-list :data-source="diagnoses[c.id]!.checks">
|
||||
<template #renderItem="{ item: ch }">
|
||||
<a-list-item>
|
||||
<a-flex vertical :gap="4" style="width: 100%">
|
||||
<a-space wrap>
|
||||
<a-tag :color="ch.ok ? 'success' : 'error'" :bordered="false">
|
||||
{{ ch.ok ? 'موفق' : 'ناموفق' }}
|
||||
</a-tag>
|
||||
<a-typography-text strong>{{ ch.name }}</a-typography-text>
|
||||
<a-typography-text type="secondary">
|
||||
{{ ch.status || 'خطای شبکه' }}
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
<a-typography-text type="secondary" dir="ltr" code>
|
||||
{{ ch.path }}
|
||||
</a-typography-text>
|
||||
<a-typography-text type="secondary">{{ ch.info }}</a-typography-text>
|
||||
</a-flex>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</p>
|
||||
<ul>
|
||||
<li v-for="ch in diagnoses[c.id]!.checks" :key="ch.path" :class="ch.ok ? 'ok-line' : 'bad-line'">
|
||||
<strong>{{ ch.ok ? '✓' : '✗' }} {{ ch.name }}</strong>
|
||||
<span class="code">{{ ch.status || 'خطای شبکه' }}</span>
|
||||
<span class="ltr path">{{ ch.path }}</span>
|
||||
<span class="info">{{ ch.info }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<UiButton size="sm" variant="secondary" :loading="busyId === c.id" @click="test(c)">
|
||||
تست اتصال
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="secondary" :loading="busyId === c.id" @click="diagnose(c)">
|
||||
بررسی مسیرها
|
||||
</UiButton>
|
||||
<UiButton
|
||||
size="sm"
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="10">
|
||||
<a-space wrap>
|
||||
<a-button :loading="busyId === c.id" @click="test(c)">تست اتصال</a-button>
|
||||
<a-button :loading="busyId === c.id" @click="diagnose(c)">بررسی مسیرها</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="!c.enabled"
|
||||
@click="sync(c, 'incremental')"
|
||||
>
|
||||
همگامسازی
|
||||
</UiButton>
|
||||
<UiButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
:disabled="!c.enabled"
|
||||
@click="sync(c, 'full')"
|
||||
>
|
||||
</a-button>
|
||||
<a-button :disabled="!c.enabled" @click="sync(c, 'full')">
|
||||
همگامسازی کامل
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="secondary" :disabled="!c.enabled" @click="toggleWebhook(c)">
|
||||
</a-button>
|
||||
<a-button :disabled="!c.enabled" @click="toggleWebhook(c)">
|
||||
{{ c.webhookRegistered ? 'حذف وبهوک' : 'ثبت وبهوک' }}
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="secondary" @click="startEdit(c)">ویرایش</UiButton>
|
||||
<UiButton size="sm" variant="secondary" @click="toggleEnabled(c)">
|
||||
</a-button>
|
||||
<a-button @click="startEdit(c)">ویرایش</a-button>
|
||||
<a-button @click="toggleEnabled(c)">
|
||||
{{ c.enabled ? 'غیرفعال' : 'فعال' }}
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="secondary" @click="remove(c)">حذف</UiButton>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</UiCard>
|
||||
</a-button>
|
||||
<a-button danger @click="remove(c)">حذف</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
<UiCard title="آخرین سفارشهای واردشده">
|
||||
<UiEmptyState
|
||||
<a-card title="آخرین سفارشهای واردشده" :bordered="false">
|
||||
<a-empty
|
||||
v-if="!orders.length"
|
||||
title="سفارشی نیست"
|
||||
description="همگامسازی را بزنید یا وبهوک را ثبت کنید."
|
||||
description="سفارشی نیست. همگامسازی را بزنید یا وبهوک را ثبت کنید."
|
||||
/>
|
||||
<div v-else class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>فروشگاه</th>
|
||||
<th>کد سفارش</th>
|
||||
<th>مشتری</th>
|
||||
<th>وضعیت سفارش</th>
|
||||
<th>وضعیت پرداخت</th>
|
||||
<th>مبلغ (ریال)</th>
|
||||
<th>تاریخ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="o in orders" :key="o.id">
|
||||
<td>{{ o.connectionLabel }}</td>
|
||||
<td>{{ o.orderReference || `#${o.externalId}` }}</td>
|
||||
<td>
|
||||
<RouterLink v-if="o.personId" :to="`/contacts/people/${o.personId}`">
|
||||
{{ o.personName || 'مشاهده' }}
|
||||
<a-table
|
||||
v-else
|
||||
:columns="orderColumns"
|
||||
:data-source="orders"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'فروشگاه'">
|
||||
{{ (record as MontifyOrderDto).connectionLabel }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'کد سفارش'">
|
||||
{{
|
||||
(record as MontifyOrderDto).orderReference ||
|
||||
`#${(record as MontifyOrderDto).externalId}`
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'مشتری'">
|
||||
<RouterLink
|
||||
v-if="(record as MontifyOrderDto).personId"
|
||||
:to="`/contacts/people/${(record as MontifyOrderDto).personId}`"
|
||||
>
|
||||
{{ (record as MontifyOrderDto).personName || 'مشاهده' }}
|
||||
</RouterLink>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td>{{ o.orderStatus || '—' }}</td>
|
||||
<td>{{ o.paymentStatus || '—' }}</td>
|
||||
<td>{{ formatAmount(o.discountTotalPrice ?? o.totalPrice) }}</td>
|
||||
<td>{{ o.createdAt ? formatJalaliNumeric(new Date(o.createdAt)) : '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.hint,
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
.ltr {
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.info {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
.badge.good {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.badge.bad {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
.badge.run {
|
||||
background: #e0f2fe;
|
||||
color: #075985;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
align-content: start;
|
||||
justify-content: end;
|
||||
max-width: 340px;
|
||||
}
|
||||
.diag {
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.6rem;
|
||||
background: #f8fafc;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.diag ul {
|
||||
list-style: none;
|
||||
margin: 0.3rem 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.diag li {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.diag .code {
|
||||
font-weight: 700;
|
||||
}
|
||||
.diag .path,
|
||||
.diag .info {
|
||||
color: #64748b;
|
||||
word-break: break-all;
|
||||
}
|
||||
.ok-line strong {
|
||||
color: #166534;
|
||||
}
|
||||
.bad-line strong {
|
||||
color: #991b1b;
|
||||
}
|
||||
.warn {
|
||||
color: #b45309;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: right;
|
||||
padding: 0.55rem;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
th {
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.item {
|
||||
flex-direction: column;
|
||||
}
|
||||
.actions {
|
||||
max-width: none;
|
||||
justify-content: start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template v-else-if="column.key === 'وضعیت سفارش'">
|
||||
{{ (record as MontifyOrderDto).orderStatus || '—' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'وضعیت پرداخت'">
|
||||
{{ (record as MontifyOrderDto).paymentStatus || '—' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'مبلغ (ریال)'">
|
||||
{{
|
||||
formatAmount(
|
||||
(record as MontifyOrderDto).discountTotalPrice ??
|
||||
(record as MontifyOrderDto).totalPrice,
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'تاریخ'">
|
||||
{{
|
||||
(record as MontifyOrderDto).createdAt
|
||||
? formatJalaliNumeric(new Date((record as MontifyOrderDto).createdAt!))
|
||||
: '—'
|
||||
}}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</a-spin>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -790,12 +790,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">یادداشتها</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
فکر کنید، یادداشت کنید، هیچ چیز را فراموش نکنید.
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
|
||||
@@ -896,7 +891,7 @@ onBeforeUnmount(() => {
|
||||
</a-button>
|
||||
</a-empty>
|
||||
|
||||
<a-row v-else :gutter="[12, 12]" align="top">
|
||||
<a-row v-else :gutter="[12, 12]" align="stretch">
|
||||
<a-col
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
@@ -919,17 +914,27 @@ onBeforeUnmount(() => {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: `${token.marginXS}px`,
|
||||
minHeight: viewMode === 'list' ? undefined : '88px',
|
||||
width: '100%',
|
||||
aspectRatio: viewMode === 'list' ? undefined : '1 / 1',
|
||||
overflow: 'hidden',
|
||||
boxSizing: 'border-box',
|
||||
}"
|
||||
@click="openEditor('default', note)"
|
||||
@keydown.enter="openEditor('default', note)"
|
||||
>
|
||||
<a-flex justify="space-between" align="flex-start" :gap="token.marginXXS">
|
||||
<a-flex
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
:gap="token.marginXXS"
|
||||
:style="{ flexShrink: 0 }"
|
||||
>
|
||||
<a-typography-text
|
||||
v-if="note.title"
|
||||
strong
|
||||
:ellipsis="viewMode === 'grid'"
|
||||
:style="{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: `${token.fontSizeLG}px`,
|
||||
lineHeight: token.lineHeight,
|
||||
wordBreak: 'break-word',
|
||||
@@ -971,14 +976,25 @@ onBeforeUnmount(() => {
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<div
|
||||
:style="{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: `${token.marginXXS}px`,
|
||||
}"
|
||||
>
|
||||
<a-flex
|
||||
v-if="note.checklist?.length"
|
||||
vertical
|
||||
:gap="token.marginXXS"
|
||||
:style="{ overflow: 'hidden' }"
|
||||
@click.stop
|
||||
>
|
||||
<a-checkbox
|
||||
v-for="item in note.checklist.slice(0, 6)"
|
||||
v-for="item in note.checklist.slice(0, viewMode === 'list' ? 6 : 4)"
|
||||
:key="item.id"
|
||||
:checked="item.done"
|
||||
@change="toggleCardChecklist(note, item.id)"
|
||||
@@ -986,20 +1002,24 @@ onBeforeUnmount(() => {
|
||||
<a-typography-text
|
||||
:delete="item.done"
|
||||
:type="item.done ? 'secondary' : undefined"
|
||||
:ellipsis="viewMode === 'grid'"
|
||||
>
|
||||
{{ item.text }}
|
||||
</a-typography-text>
|
||||
</a-checkbox>
|
||||
<a-typography-text
|
||||
v-if="note.checklist.length > 6"
|
||||
v-if="note.checklist.length > (viewMode === 'list' ? 6 : 4)"
|
||||
type="secondary"
|
||||
>
|
||||
+{{ toFaDigits(note.checklist.length - 6) }} مورد دیگر
|
||||
+{{
|
||||
toFaDigits(note.checklist.length - (viewMode === 'list' ? 6 : 4))
|
||||
}}
|
||||
مورد دیگر
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-typography-paragraph
|
||||
v-else-if="note.body"
|
||||
:ellipsis="{ rows: 6 }"
|
||||
:ellipsis="{ rows: viewMode === 'list' ? 6 : 4 }"
|
||||
:style="{
|
||||
marginBottom: 0,
|
||||
color: token.colorText,
|
||||
@@ -1016,9 +1036,10 @@ onBeforeUnmount(() => {
|
||||
:alt="firstImage(note)!.fileName"
|
||||
:style="{
|
||||
width: '100%',
|
||||
maxHeight: '140px',
|
||||
maxHeight: viewMode === 'list' ? '140px' : '72px',
|
||||
objectFit: 'cover',
|
||||
borderRadius: `${token.borderRadius}px`,
|
||||
flexShrink: 0,
|
||||
}"
|
||||
/>
|
||||
|
||||
@@ -1038,6 +1059,7 @@ onBeforeUnmount(() => {
|
||||
:style="{ width: '100%', height: '32px' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-flex
|
||||
v-if="
|
||||
@@ -1048,6 +1070,7 @@ onBeforeUnmount(() => {
|
||||
"
|
||||
wrap="wrap"
|
||||
:gap="token.marginXXS"
|
||||
:style="{ flexShrink: 0, overflow: 'hidden', maxHeight: '48px' }"
|
||||
>
|
||||
<a-tag
|
||||
v-for="tag in note.tags ?? []"
|
||||
@@ -1084,7 +1107,8 @@ onBeforeUnmount(() => {
|
||||
|
||||
<a-typography-text
|
||||
type="secondary"
|
||||
:style="{ fontSize: `${token.fontSizeSM}px` }"
|
||||
:ellipsis="true"
|
||||
:style="{ fontSize: `${token.fontSizeSM}px`, flexShrink: 0 }"
|
||||
>
|
||||
{{ formatNoteTime(note.updatedAt) }}
|
||||
<template v-if="note.person"> · {{ note.person.fullName }}</template>
|
||||
@@ -1107,13 +1131,16 @@ onBeforeUnmount(() => {
|
||||
border: `1px dashed ${token.colorBorder}`,
|
||||
padding: `${token.padding}px`,
|
||||
cursor: 'pointer',
|
||||
minHeight: '88px',
|
||||
width: '100%',
|
||||
aspectRatio: viewMode === 'list' ? undefined : '1 / 1',
|
||||
minHeight: viewMode === 'list' ? '88px' : undefined,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: `${token.marginXS}px`,
|
||||
background: token.colorBgContainer,
|
||||
boxSizing: 'border-box',
|
||||
}"
|
||||
@click="openEditor()"
|
||||
@keydown.enter="openEditor()"
|
||||
|
||||
@@ -3,10 +3,32 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { UiButton, UiCard, UiEmptyState, UiField, UiInput, UiModal, UiPageHeader } from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import PipelineMergeWizard from '@/components/pipeline/PipelineMergeWizard.vue';
|
||||
import type { PipelineDto } from '@mento/shared';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
ListItem as AListItem,
|
||||
ListItemMeta as AListItemMeta,
|
||||
Modal as AModal,
|
||||
Row as ARow,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
type PipelineListItem = PipelineDto & {
|
||||
dealCount?: number | null;
|
||||
@@ -25,6 +47,13 @@ const deleteCountReady = ref(false);
|
||||
const deleting = ref(false);
|
||||
|
||||
const canMerge = computed(() => pipelines.value.length >= 2);
|
||||
const listEmpty = computed(() => !loading.value && !pipelines.value.length);
|
||||
|
||||
function pipelineMeta(pipeline: PipelineListItem) {
|
||||
const parts = [`${toFaDigits(pipeline.stages.length)} مرحله`];
|
||||
if (pipeline.dealCount != null) parts.push(`${toFaDigits(pipeline.dealCount)} معامله`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function apiError(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
@@ -73,11 +102,11 @@ async function createPipeline() {
|
||||
function deletePrompt(pipeline: PipelineListItem) {
|
||||
if (!deleteCountReady.value) return 'در حال شمارش معاملات این مسیر…';
|
||||
const count = pipeline.dealCount ?? 0;
|
||||
const name = pipeline.name;
|
||||
const pipelineName = pipeline.name;
|
||||
if (count > 0) {
|
||||
return `مسیر «${name}» شامل ${toFaDigits(count)} معامله است. با حذف، این معاملات هم پاک میشوند. از حذف مطمئن هستید؟`;
|
||||
return `مسیر «${pipelineName}» شامل ${toFaDigits(count)} معامله است. با حذف، این معاملات هم پاک میشوند. از حذف مطمئن هستید؟`;
|
||||
}
|
||||
return `مسیر «${name}» معاملهای ندارد. از حذف آن مطمئن هستید؟`;
|
||||
return `مسیر «${pipelineName}» معاملهای ندارد. از حذف آن مطمئن هستید؟`;
|
||||
}
|
||||
|
||||
async function openDelete(pipeline: PipelineListItem) {
|
||||
@@ -146,195 +175,97 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page">
|
||||
<UiPageHeader
|
||||
title="تنظیمات مسیر فروش"
|
||||
lead="مدیریت کاریزهای فروش و آمادهسازی ادغام مسیرها"
|
||||
<a-flex vertical :gap="16">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">تنظیمات مسیر فروش</a-typography-title>
|
||||
|
||||
<a-alert v-if="okMsg" type="success" show-icon :message="okMsg" closable @close="okMsg = ''" />
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-form layout="vertical" @finish="createPipeline">
|
||||
<a-divider orientation="right">کاریز جدید</a-divider>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام کاریز" required>
|
||||
<a-input
|
||||
v-model:value="name"
|
||||
placeholder="مثلاً فروش سازمانی"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" html-type="submit" :loading="saving">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
افزودن کاریز
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">فهرست کاریزها</a-divider>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-empty
|
||||
v-if="listEmpty"
|
||||
description="کاریزی تعریف نشده. اولین مسیر فروش را اضافه کنید."
|
||||
/>
|
||||
|
||||
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<form class="crm-form" @submit.prevent="createPipeline">
|
||||
<UiCard>
|
||||
<template #header>
|
||||
<div class="card-title">
|
||||
<UiIcon name="plus" :size="18" />
|
||||
<h3>کاریز جدید</h3>
|
||||
</div>
|
||||
<a-list v-else-if="pipelines.length" item-layout="horizontal" :data-source="pipelines">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-space wrap>
|
||||
<a-button :disabled="!canMerge" @click="openMerge(item)">ادغام</a-button>
|
||||
<a-button danger @click="openDelete(item)">حذف</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<div class="form-grid">
|
||||
<UiField label="نام کاریز" required for-id="pipeline-name">
|
||||
<UiInput id="pipeline-name" v-model="name" required placeholder="مثلاً فروش سازمانی" />
|
||||
</UiField>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton type="submit" :loading="saving">افزودن کاریز</UiButton>
|
||||
<a-list-item-meta :description="pipelineMeta(item)">
|
||||
<template #title>
|
||||
<a-space wrap>
|
||||
<a-typography-text strong>{{ item.name }}</a-typography-text>
|
||||
<a-tag v-if="item.isDefault" color="processing" :bordered="false">پیشفرض</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
</UiCard>
|
||||
</form>
|
||||
|
||||
<div v-if="pipelines.length" class="list">
|
||||
<UiCard v-for="pipeline in pipelines" :key="pipeline.id" padding="sm" class="pipeline-card">
|
||||
<div class="row">
|
||||
<span class="dot" aria-hidden="true" />
|
||||
<div class="info">
|
||||
<div class="title-row">
|
||||
<strong>{{ pipeline.name }}</strong>
|
||||
<em v-if="pipeline.isDefault">پیشفرض</em>
|
||||
</div>
|
||||
<span class="muted">
|
||||
{{ toFaDigits(pipeline.stages.length) }} مرحله
|
||||
<template v-if="pipeline.dealCount != null">
|
||||
· {{ toFaDigits(pipeline.dealCount) }} معامله
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ops">
|
||||
<UiButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:disabled="!canMerge"
|
||||
@click="openMerge(pipeline)"
|
||||
>
|
||||
ادغام
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="danger" @click="openDelete(pipeline)">
|
||||
حذف
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
</a-list>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!loading"
|
||||
title="کاریزی تعریف نشده."
|
||||
description="اولین مسیر فروش را اضافه کنید."
|
||||
/>
|
||||
|
||||
<UiModal
|
||||
<a-modal
|
||||
:open="!!deleteTarget"
|
||||
title="حذف کاریز"
|
||||
@close="closeDelete"
|
||||
:confirm-loading="deleting"
|
||||
ok-text="حذف کاریز"
|
||||
cancel-text="انصراف"
|
||||
ok-type="danger"
|
||||
:ok-button-props="{ disabled: !deleteCountReady }"
|
||||
destroy-on-close
|
||||
@ok="confirmDelete"
|
||||
@cancel="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>
|
||||
<a-typography-text v-if="deleteTarget">
|
||||
{{ deletePrompt(deleteTarget) }}
|
||||
</a-typography-text>
|
||||
</a-modal>
|
||||
|
||||
<UiModal :open="mergeOpen" title="ادغام کاریز" @close="closeMerge">
|
||||
<a-modal
|
||||
v-model:open="mergeOpen"
|
||||
title="ادغام کاریز"
|
||||
:footer="null"
|
||||
width="720px"
|
||||
destroy-on-close
|
||||
@cancel="closeMerge"
|
||||
>
|
||||
<PipelineMergeWizard
|
||||
:open="mergeOpen"
|
||||
:source-id="mergeSourceId"
|
||||
@close="closeMerge"
|
||||
@done="onMergeDone"
|
||||
/>
|
||||
</UiModal>
|
||||
</section>
|
||||
</a-modal>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.crm-page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.card-title h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1 1 12rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title-row em {
|
||||
font-style: normal;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.ops {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.confirm {
|
||||
margin: 0;
|
||||
line-height: 1.8;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -255,9 +255,6 @@ onMounted(async () => {
|
||||
<a-breadcrumb-item>{{ title }}</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">{{ title }}</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
تعریف و ویرایش کالا و خدمات، قیمتگذاری و مدیریت موجودی انبار
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-space :size="8" wrap>
|
||||
<a-button type="primary" :loading="saving" @click="submit">
|
||||
|
||||
@@ -407,9 +407,6 @@ onMounted(load);
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">محصولات</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
مدیریت محصولات، قیمتها و موجودی
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-space :size="8" wrap @click.stop>
|
||||
<a-button :disabled="busy" :loading="pageBusy" @click="exportExcel">
|
||||
|
||||
@@ -6,7 +6,37 @@ import { api } from '@/lib/api';
|
||||
import { canAssignToOthers } from '@/lib/assign-access';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type { OrgUserDto, ProjectCardDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import {
|
||||
Affix as AAffix,
|
||||
Alert as AAlert,
|
||||
Breadcrumb as ABreadcrumb,
|
||||
BreadcrumbItem as ABreadcrumbItem,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Descriptions as ADescriptions,
|
||||
DescriptionsItem as ADescriptionsItem,
|
||||
Divider as ADivider,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import {
|
||||
CheckOutlined,
|
||||
CloseOutlined,
|
||||
DeleteOutlined,
|
||||
FileTextOutlined,
|
||||
SaveOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -43,6 +73,29 @@ const tagsRaw = ref('');
|
||||
const selectedAssigneeIds = ref<string[]>([]);
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
|
||||
const userOptions = computed(() =>
|
||||
users.value.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
);
|
||||
|
||||
const parsedTags = computed(() => parseTags(tagsRaw.value));
|
||||
|
||||
const summaryItems = computed(() => [
|
||||
{ label: 'عنوان', value: title.value.trim() || '—' },
|
||||
{ label: 'برچسبها', value: parsedTags.value.length ? parsedTags.value.join('، ') : '—' },
|
||||
{
|
||||
label: 'مسئولان',
|
||||
value: selectedAssigneeIds.value.length
|
||||
? users.value
|
||||
.filter((u) => selectedAssigneeIds.value.includes(u.id))
|
||||
.map((u) => u.fullName)
|
||||
.join('، ') || `${selectedAssigneeIds.value.length} نفر`
|
||||
: '—',
|
||||
},
|
||||
{ label: 'یادداشت', value: notes.value.trim() ? 'دارد' : 'ندارد' },
|
||||
]);
|
||||
|
||||
const canSave = computed(() => !saving.value && title.value.trim().length >= 2);
|
||||
|
||||
function parseTags(raw: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
@@ -161,293 +214,148 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page" :class="{ embedded }">
|
||||
<div v-if="embedded" class="embed-actions">
|
||||
<button
|
||||
class="btn primary"
|
||||
type="button"
|
||||
:disabled="saving || title.trim().length < 2"
|
||||
@click="save"
|
||||
>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex v-if="embedded" wrap="wrap" :gap="8">
|
||||
<a-button type="primary" :loading="saving" :disabled="!canSave" @click="save">
|
||||
<template #icon><SaveOutlined /></template>
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</button>
|
||||
<button class="btn cancel" type="button" @click="cancel">انصراف</button>
|
||||
<button v-if="!isNew" class="btn danger" type="button" @click="removeCard">حذف</button>
|
||||
</div>
|
||||
<header v-if="!embedded" class="desktop-head">
|
||||
<div class="title-block">
|
||||
<span class="doc-ico"><UiIcon name="document" :size="22" /></span>
|
||||
<div>
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
<p class="crumb">
|
||||
</a-button>
|
||||
<a-button @click="cancel">انصراف</a-button>
|
||||
<a-button v-if="!isNew" danger @click="removeCard">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
حذف
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<a-flex
|
||||
v-else
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="wrap"
|
||||
:gap="12"
|
||||
>
|
||||
<a-flex vertical :gap="4">
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item>
|
||||
<RouterLink :to="{ name: 'projects' }">پروژهها</RouterLink>
|
||||
<span>›</span>
|
||||
<strong>{{ pageTitle }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="desk-actions">
|
||||
<button
|
||||
class="btn primary"
|
||||
type="button"
|
||||
:disabled="saving || title.trim().length < 2"
|
||||
@click="save"
|
||||
>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
<a-flex align="center" :gap="8">
|
||||
<FileTextOutlined />
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">{{ pageTitle }}</a-typography-title>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
<a-space wrap>
|
||||
<a-button type="primary" :loading="saving" :disabled="!canSave" @click="save">
|
||||
<template #icon><CheckOutlined /></template>
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</button>
|
||||
<button class="btn cancel" type="button" @click="cancel">انصراف</button>
|
||||
<button v-if="!isNew" class="btn danger" type="button" @click="removeCard">حذف</button>
|
||||
</div>
|
||||
</header>
|
||||
</a-button>
|
||||
<a-button @click="cancel">
|
||||
<template #icon><CloseOutlined /></template>
|
||||
انصراف
|
||||
</a-button>
|
||||
<a-button v-if="!isNew" danger @click="removeCard">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
حذف
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<header v-if="!embedded" class="mobile-head">
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
aria-label="ذخیره"
|
||||
:disabled="saving || title.trim().length < 2"
|
||||
@click="save"
|
||||
>
|
||||
<UiIcon name="check-plain" :size="22" />
|
||||
</button>
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
<button class="icon-btn" type="button" aria-label="بستن" @click="cancel">
|
||||
<UiIcon name="x" :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<form v-else class="card" @submit.prevent="save">
|
||||
<div class="field">
|
||||
<label for="card-title">عنوان</label>
|
||||
<input id="card-title" v-model="title" type="text" required minlength="2" maxlength="200" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="card-tags">برچسبها</label>
|
||||
<input
|
||||
id="card-tags"
|
||||
v-model="tagsRaw"
|
||||
type="text"
|
||||
<a-spin :spinning="loading">
|
||||
<a-row v-if="!loading" :gutter="[24, 24]" align="top">
|
||||
<a-col :xs="24" :lg="17" :xl="18">
|
||||
<a-card :bordered="false">
|
||||
<a-form layout="vertical" @finish="save">
|
||||
<a-divider orientation="right">اطلاعات کارت</a-divider>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="عنوان" required>
|
||||
<a-input
|
||||
v-model:value="title"
|
||||
:maxlength="200"
|
||||
placeholder="عنوان کارت"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="برچسبها">
|
||||
<a-input
|
||||
v-model:value="tagsRaw"
|
||||
placeholder="فوری، پشتیبانی (با کاما جدا کنید)"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="card-assignees">مسئولان کارت</label>
|
||||
<select id="card-assignees" v-model="selectedAssigneeIds" multiple class="multi" :disabled="!canAssignOthers">
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||||
</select>
|
||||
<small v-if="canAssignOthers" class="hint">با نگه داشتن Ctrl میتوانید چند نفر را انتخاب کنید.</small>
|
||||
<small v-else class="hint">بدون دسترسی «تخصیص کارت پروژه به دیگران» کارت فقط برای خودتان ثبت میشود.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="card-notes">یادداشت</label>
|
||||
<textarea id="card-notes" v-model="notes" rows="5" maxlength="4000" />
|
||||
</div>
|
||||
<div v-if="!embedded" class="mobile-actions">
|
||||
<button class="btn primary" type="submit" :disabled="saving || title.trim().length < 2">
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</button>
|
||||
<button v-if="!isNew" class="btn danger" type="button" @click="removeCard">حذف</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="مسئولان کارت">
|
||||
<a-select
|
||||
v-model:value="selectedAssigneeIds"
|
||||
mode="multiple"
|
||||
:options="userOptions"
|
||||
:disabled="!canAssignOthers"
|
||||
placeholder="انتخاب مسئولان"
|
||||
style="width: 100%"
|
||||
option-filter-prop="label"
|
||||
/>
|
||||
<a-typography-text type="secondary">
|
||||
<template v-if="canAssignOthers">
|
||||
میتوانید چند نفر را انتخاب کنید.
|
||||
</template>
|
||||
<template v-else>
|
||||
بدون دسترسی «تخصیص کارت پروژه به دیگران» کارت فقط برای خودتان ثبت میشود.
|
||||
</template>
|
||||
</a-typography-text>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
<a-divider orientation="right">یادداشت</a-divider>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :span="24">
|
||||
<a-form-item label="یادداشت">
|
||||
<a-textarea
|
||||
v-model:value="notes"
|
||||
:rows="5"
|
||||
:maxlength="4000"
|
||||
style="width: 100%"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
.page.embedded {
|
||||
max-width: none;
|
||||
}
|
||||
<a-flex v-if="!embedded" justify="flex-end" wrap="wrap" :gap="8">
|
||||
<a-button type="primary" html-type="submit" :loading="saving" :disabled="!canSave">
|
||||
<template #icon><SaveOutlined /></template>
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</a-button>
|
||||
<a-button v-if="!isNew" danger @click="removeCard">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
حذف
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
.embed-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.desktop-head {
|
||||
display: none;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.doc-ico {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #ecfdf5;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
margin: 0.2rem 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.crumb a {
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.desk-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mobile-head {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobile-head h1 {
|
||||
text-align: center;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
color: #64748b;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.75rem;
|
||||
font: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
select.multi {
|
||||
min-height: 110px;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 5.5rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.95rem;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 700;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
|
||||
}
|
||||
|
||||
.btn.cancel {
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
background: #fff;
|
||||
color: #b91c1c;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.mobile-actions {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.desktop-head {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mobile-head,
|
||||
.mobile-actions {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<a-col :xs="24" :lg="7" :xl="6">
|
||||
<a-affix :offset-top="24">
|
||||
<a-card title="خلاصه کارت" :bordered="false">
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item
|
||||
v-for="item in summaryItems"
|
||||
:key="item.label"
|
||||
:label="item.label"
|
||||
>
|
||||
{{ item.value }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</a-affix>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -12,16 +12,42 @@ import type {
|
||||
WebhookEvent,
|
||||
} from '@mento/shared';
|
||||
import { WEBHOOK_EVENTS } from '@mento/shared';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
ListItem as AListItem,
|
||||
ListItemMeta as AListItemMeta,
|
||||
Row as ARow,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { CopyOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
|
||||
const ACheckableTag = ATag.CheckableTag;
|
||||
|
||||
const KEY_COL_LABELS = ['نام', 'پیشوند', 'آخرین استفاده', 'وضعیت'] as const;
|
||||
const DELIVERY_COL_LABELS = ['زمان', 'رویداد', 'وضعیت', 'کد', 'تلاش'] as const;
|
||||
|
||||
registerTableColumns('public-api.keys', [...KEY_COL_LABELS]);
|
||||
registerTableColumns('public-api.deliveries', [...DELIVERY_COL_LABELS]);
|
||||
const keyTableCols = useTableColumns(() => 'public-api.keys');
|
||||
const deliveryTableCols = useTableColumns(() => 'public-api.deliveries');
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
@@ -50,6 +76,37 @@ const EVENT_LABELS: Record<WebhookEvent, string> = {
|
||||
'activity.created': 'ایجاد فعالیت',
|
||||
};
|
||||
|
||||
const keyColumns = computed<TableColumnsType<OrganizationApiKeyDto>>(() => {
|
||||
const all: TableColumnsType<OrganizationApiKeyDto> = [
|
||||
{ title: 'نام', key: 'نام', dataIndex: 'name' },
|
||||
{ title: 'پیشوند', key: 'پیشوند' },
|
||||
{ title: 'آخرین استفاده', key: 'آخرین استفاده' },
|
||||
{ title: 'وضعیت', key: 'وضعیت', width: 100 },
|
||||
{ title: '', key: 'عملیات', width: 120 },
|
||||
];
|
||||
return all.filter((col) => {
|
||||
const key = String(col.key ?? '');
|
||||
if (key === 'عملیات') return true;
|
||||
if (!KEY_COL_LABELS.includes(key as (typeof KEY_COL_LABELS)[number])) return true;
|
||||
return keyTableCols.isVisible(key);
|
||||
});
|
||||
});
|
||||
|
||||
const deliveryColumns = computed<TableColumnsType<WebhookDeliveryDto>>(() => {
|
||||
const all: TableColumnsType<WebhookDeliveryDto> = [
|
||||
{ title: 'زمان', key: 'زمان' },
|
||||
{ title: 'رویداد', key: 'رویداد' },
|
||||
{ title: 'وضعیت', key: 'وضعیت' },
|
||||
{ title: 'کد', key: 'کد', width: 80 },
|
||||
{ title: 'تلاش', key: 'تلاش', width: 80 },
|
||||
];
|
||||
return all.filter((col) => {
|
||||
const key = String(col.key ?? '');
|
||||
if (!DELIVERY_COL_LABELS.includes(key as (typeof DELIVERY_COL_LABELS)[number])) return true;
|
||||
return deliveryTableCols.isVisible(key);
|
||||
});
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -161,30 +218,32 @@ async function copyText(text: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function endpointEventsLabel(ep: WebhookEndpointDto) {
|
||||
return ep.events.map((e) => EVENT_LABELS[e as WebhookEvent] ?? e).join('، ');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<UiPageHeader
|
||||
title="API و وبهوک"
|
||||
description="کلید عمومی برای یکپارچهسازی و ارسال رویداد به سرور شما"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" :disabled="loading" @click="load">
|
||||
<UiIcon name="refresh" />
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">API و وبهوک</a-typography-title>
|
||||
<a-button :disabled="loading" @click="load">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
|
||||
|
||||
<UiCard title="کلیدهای API">
|
||||
<p class="hint">
|
||||
<a-spin :spinning="loading">
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">کلیدهای API</a-divider>
|
||||
<a-typography-paragraph type="secondary">
|
||||
هدر درخواست:
|
||||
<code>X-Api-Key: mk_…</code>
|
||||
یا
|
||||
@@ -193,260 +252,207 @@ onMounted(() => {
|
||||
<code>/api/public/v1/people</code>
|
||||
و
|
||||
<code>/api/public/v1/deals</code>
|
||||
</p>
|
||||
<div class="row">
|
||||
<UiField label="نام کلید">
|
||||
<UiInput v-model="newKeyName" placeholder="مثلاً اتصال سایت" />
|
||||
</UiField>
|
||||
<UiButton :loading="saving" @click="createKey">ساخت کلید</UiButton>
|
||||
</div>
|
||||
<div v-if="createdRawKey" class="raw-key">
|
||||
<code>{{ createdRawKey }}</code>
|
||||
<UiButton variant="secondary" @click="copyText(createdRawKey)">کپی</UiButton>
|
||||
</div>
|
||||
<UiEmptyState
|
||||
</a-typography-paragraph>
|
||||
|
||||
<a-form layout="vertical" @finish="createKey">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام کلید" required>
|
||||
<a-input v-model:value="newKeyName" placeholder="مثلاً اتصال سایت" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" html-type="submit" :loading="saving">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
ساخت کلید
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<a-alert
|
||||
v-if="createdRawKey"
|
||||
type="success"
|
||||
show-icon
|
||||
:message="createdRawKey"
|
||||
:style="{ marginBottom: '12px' }"
|
||||
>
|
||||
<template #action>
|
||||
<a-button @click="copyText(createdRawKey)">
|
||||
<template #icon><CopyOutlined /></template>
|
||||
کپی
|
||||
</a-button>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-empty
|
||||
v-if="!loading && !keys.length"
|
||||
title="کلیدی نیست"
|
||||
description="برای دسترسی خارجی یک کلید بسازید."
|
||||
description="کلیدی نیست. برای دسترسی خارجی یک کلید بسازید."
|
||||
/>
|
||||
<div v-else class="table-wrap">
|
||||
<div class="tc-row">
|
||||
<template v-else-if="keys.length">
|
||||
<a-flex justify="flex-end" :style="{ marginBottom: '12px' }">
|
||||
<TableColumnsMenu table-key="public-api.keys" align="end" />
|
||||
</div>
|
||||
<table v-table-cols="'public-api.keys'">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>نام</th>
|
||||
<th>پیشوند</th>
|
||||
<th>آخرین استفاده</th>
|
||||
<th>وضعیت</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="k in keys" :key="k.id">
|
||||
<td>{{ k.name }}</td>
|
||||
<td><code>{{ k.prefix }}…</code></td>
|
||||
<td>
|
||||
</a-flex>
|
||||
<a-table
|
||||
:columns="keyColumns"
|
||||
:data-source="keys"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: 640 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'نام'">
|
||||
{{ (record as OrganizationApiKeyDto).name }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'پیشوند'">
|
||||
<code>{{ (record as OrganizationApiKeyDto).prefix }}…</code>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'آخرین استفاده'">
|
||||
{{
|
||||
k.lastUsedAt
|
||||
? formatJalaliNumeric(new Date(k.lastUsedAt))
|
||||
(record as OrganizationApiKeyDto).lastUsedAt
|
||||
? formatJalaliNumeric(new Date((record as OrganizationApiKeyDto).lastUsedAt!))
|
||||
: '—'
|
||||
}}
|
||||
</td>
|
||||
<td>{{ k.revokedAt ? 'باطل' : 'فعال' }}</td>
|
||||
<td>
|
||||
<UiButton
|
||||
v-if="!k.revokedAt"
|
||||
variant="secondary"
|
||||
@click="revokeKey(k.id)"
|
||||
</template>
|
||||
<template v-else-if="column.key === 'وضعیت'">
|
||||
<a-tag
|
||||
:color="(record as OrganizationApiKeyDto).revokedAt ? 'default' : 'success'"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ (record as OrganizationApiKeyDto).revokedAt ? 'باطل' : 'فعال' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'عملیات'">
|
||||
<a-button
|
||||
v-if="!(record as OrganizationApiKeyDto).revokedAt"
|
||||
danger
|
||||
@click="revokeKey((record as OrganizationApiKeyDto).id)"
|
||||
>
|
||||
باطلسازی
|
||||
</UiButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="وبهوکهای خروجی">
|
||||
<div class="form-grid">
|
||||
<UiField label="نام">
|
||||
<UiInput v-model="whName" placeholder="مثلاً n8n" />
|
||||
</UiField>
|
||||
<UiField label="آدرس URL">
|
||||
<UiInput v-model="whUrl" placeholder="https://example.com/hook" />
|
||||
</UiField>
|
||||
</div>
|
||||
<div class="events">
|
||||
<button
|
||||
v-for="ev in WEBHOOK_EVENTS"
|
||||
:key="ev"
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ on: whEvents.includes(ev) }"
|
||||
@click="toggleEvent(ev)"
|
||||
>
|
||||
{{ EVENT_LABELS[ev] }}
|
||||
</button>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="createWebhook">افزودن وبهوک</UiButton>
|
||||
<div v-if="createdWebhookSecret" class="raw-key">
|
||||
<span>HMAC secret:</span>
|
||||
<code>{{ createdWebhookSecret }}</code>
|
||||
<UiButton variant="secondary" @click="copyText(createdWebhookSecret)">کپی</UiButton>
|
||||
</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-if="!loading && !endpoints.length"
|
||||
title="وبهوکی نیست"
|
||||
description="رویدادهای CRM به آدرس شما POST میشوند."
|
||||
/>
|
||||
<div v-else class="list">
|
||||
<article v-for="ep in endpoints" :key="ep.id" class="item">
|
||||
<div>
|
||||
<strong>{{ ep.name }}</strong>
|
||||
<p class="muted">{{ ep.url }}</p>
|
||||
<p class="muted">
|
||||
{{ ep.events.map((e) => EVENT_LABELS[e as WebhookEvent] ?? e).join('، ') }}
|
||||
</p>
|
||||
</div>
|
||||
<UiButton variant="secondary" @click="removeWebhook(ep.id)">حذف</UiButton>
|
||||
</article>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="آخرین ارسالهای وبهوک">
|
||||
<UiEmptyState
|
||||
v-if="!deliveries.length"
|
||||
title="ارسالی ثبت نشده"
|
||||
description="پس از وقوع رویدادها اینجا دیده میشوند."
|
||||
/>
|
||||
<div v-else class="table-wrap">
|
||||
<div class="tc-row">
|
||||
<TableColumnsMenu table-key="public-api.deliveries" align="end" />
|
||||
</div>
|
||||
<table v-table-cols="'public-api.deliveries'">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>زمان</th>
|
||||
<th>رویداد</th>
|
||||
<th>وضعیت</th>
|
||||
<th>کد</th>
|
||||
<th>تلاش</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="d in deliveries" :key="d.id">
|
||||
<td>{{ formatJalaliNumeric(new Date(d.createdAt)) }}</td>
|
||||
<td>{{ EVENT_LABELS[d.event as WebhookEvent] ?? d.event }}</td>
|
||||
<td>{{ d.status }}</td>
|
||||
<td>{{ d.statusCode ?? '—' }}</td>
|
||||
<td>{{ toFaDigits(d.attempts) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
.hint {
|
||||
color: #64748b;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.hint code {
|
||||
background: #f1f5f9;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.raw-key {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem;
|
||||
background: #f0fdfa;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.raw-key code {
|
||||
word-break: break-all;
|
||||
font-weight: 700;
|
||||
color: #0f766e;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.events {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.chip {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: #334155;
|
||||
}
|
||||
.chip.on {
|
||||
background: #f0fdfa;
|
||||
border-color: #99f6e4;
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: right;
|
||||
padding: 0.55rem;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
th {
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.row,
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<a-divider orientation="right">وبهوکهای خروجی</a-divider>
|
||||
<a-form layout="vertical" @finish="createWebhook">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نام" required>
|
||||
<a-input v-model:value="whName" placeholder="مثلاً n8n" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="آدرس URL" required>
|
||||
<a-input
|
||||
v-model:value="whUrl"
|
||||
placeholder="https://example.com/hook"
|
||||
dir="ltr"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="رویدادها" required>
|
||||
<a-space wrap>
|
||||
<a-checkable-tag
|
||||
v-for="ev in WEBHOOK_EVENTS"
|
||||
:key="ev"
|
||||
:checked="whEvents.includes(ev)"
|
||||
@change="() => toggleEvent(ev)"
|
||||
>
|
||||
{{ EVENT_LABELS[ev] }}
|
||||
</a-checkable-tag>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-button type="primary" html-type="submit" :loading="saving">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
افزودن وبهوک
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<a-alert
|
||||
v-if="createdWebhookSecret"
|
||||
type="success"
|
||||
show-icon
|
||||
:message="`HMAC secret: ${createdWebhookSecret}`"
|
||||
:style="{ marginTop: '12px', marginBottom: '12px' }"
|
||||
>
|
||||
<template #action>
|
||||
<a-button @click="copyText(createdWebhookSecret)">
|
||||
<template #icon><CopyOutlined /></template>
|
||||
کپی
|
||||
</a-button>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<a-empty
|
||||
v-if="!loading && !endpoints.length"
|
||||
description="وبهوکی نیست. رویدادهای CRM به آدرس شما POST میشوند."
|
||||
/>
|
||||
<a-list v-else-if="endpoints.length" item-layout="horizontal" :data-source="endpoints">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-button danger @click="removeWebhook(item.id)">حذف</a-button>
|
||||
</template>
|
||||
<a-list-item-meta
|
||||
:description="`${item.url} · ${endpointEventsLabel(item)}`"
|
||||
>
|
||||
<template #title>
|
||||
<a-typography-text strong>{{ item.name }}</a-typography-text>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
|
||||
<a-divider orientation="right">آخرین ارسالهای وبهوک</a-divider>
|
||||
<a-empty
|
||||
v-if="!deliveries.length"
|
||||
description="ارسالی ثبت نشده. پس از وقوع رویدادها اینجا دیده میشوند."
|
||||
/>
|
||||
<template v-else>
|
||||
<a-flex justify="flex-end" :style="{ marginBottom: '12px' }">
|
||||
<TableColumnsMenu table-key="public-api.deliveries" align="end" />
|
||||
</a-flex>
|
||||
<a-table
|
||||
:columns="deliveryColumns"
|
||||
:data-source="deliveries"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: 640 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'زمان'">
|
||||
{{ formatJalaliNumeric(new Date((record as WebhookDeliveryDto).createdAt)) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'رویداد'">
|
||||
{{
|
||||
EVENT_LABELS[(record as WebhookDeliveryDto).event as WebhookEvent] ??
|
||||
(record as WebhookDeliveryDto).event
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'وضعیت'">
|
||||
{{ (record as WebhookDeliveryDto).status }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'کد'">
|
||||
{{ (record as WebhookDeliveryDto).statusCode ?? '—' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'تلاش'">
|
||||
{{ toFaDigits((record as WebhookDeliveryDto).attempts) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -1339,9 +1339,6 @@ const activityMax = computed(() =>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">
|
||||
گزارشات و داشبورد
|
||||
</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
نمای جامع عملکرد فروش، مشتریان، فعالیتها و تیم
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-space wrap>
|
||||
<a-button @click="openSaveModal">
|
||||
|
||||
@@ -360,12 +360,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">رزرو و نوبتدهی</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
تعریف خدمت، انتخاب بازه آزاد و ثبت نوبت برای مشتری
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-space wrap :size="8">
|
||||
<a-button :disabled="loading" @click="load">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
|
||||
@@ -528,9 +528,6 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">هدف و پیشبینی</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
اهداف ماهانه را تنظیم و پیشبینیهای خود را مشاهده نمایید
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-button type="primary" @click="openCreate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
|
||||
@@ -417,12 +417,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">پیامک گروهی</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
ارسال انبوه پیامک به اشخاص یا لیدهای فیلترشده
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-button :disabled="loading" @click="load">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بروزرسانی
|
||||
|
||||
@@ -688,9 +688,6 @@ onUnmounted(() => {
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">
|
||||
گزارش فروش تهحساب
|
||||
</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
وزن خالص فروش و خرید بعد از مرجوعی، به تفکیک گروه و کارشناس{{ showInternet ? ' و فروش اینترنتی' : '' }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-space :size="8" wrap>
|
||||
<a-button @click="router.push({ name: 'reports' })">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+804
-1675
File diff suppressed because it is too large
Load Diff
@@ -212,12 +212,7 @@ onMounted(load);
|
||||
<template>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">تیکتها</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
درخواستهای پشتیبانی را ثبت و پیگیری کنید.
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-button type="primary" @click="goNew">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
تیکت جدید
|
||||
|
||||
@@ -9,16 +9,43 @@ import type {
|
||||
VoipCallLogDto,
|
||||
VoipConnectionDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { formatJalaliNumeric, rewriteGregorianDates, toFaDigits } from '@/lib/jalali';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
InputPassword as AInputPassword,
|
||||
Row as ARow,
|
||||
Spin as ASpin,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { CopyOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
|
||||
const CALL_COL_LABELS = [
|
||||
'جهت',
|
||||
'وضعیت',
|
||||
'از / به',
|
||||
'مشتری',
|
||||
'مدت',
|
||||
'خلاصه',
|
||||
'زمان',
|
||||
] as const;
|
||||
|
||||
registerTableColumns('voip.calls', [...CALL_COL_LABELS]);
|
||||
const callTableCols = useTableColumns(() => 'voip.calls');
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
@@ -57,12 +84,39 @@ const summaryLabel: Record<string, string> = {
|
||||
skipped: 'رد شده',
|
||||
};
|
||||
|
||||
const callColumns = computed<TableColumnsType<VoipCallLogDto>>(() => {
|
||||
const all: TableColumnsType<VoipCallLogDto> = [
|
||||
{ title: 'جهت', key: 'جهت' },
|
||||
{ title: 'وضعیت', key: 'وضعیت' },
|
||||
{ title: 'از / به', key: 'از / به' },
|
||||
{ title: 'مشتری', key: 'مشتری' },
|
||||
{ title: 'مدت', key: 'مدت' },
|
||||
{ title: 'خلاصه', key: 'خلاصه' },
|
||||
{ title: 'زمان', key: 'زمان' },
|
||||
];
|
||||
return all.filter((col) => callTableCols.isVisible(String(col.key ?? '')));
|
||||
});
|
||||
|
||||
function noteLink(c: VoipCallLogDto): string | null {
|
||||
if (!c.summaryNoteId) return null;
|
||||
if (c.personId) return `/contacts/people/${c.personId}`;
|
||||
return '/notes';
|
||||
}
|
||||
|
||||
function summaryColor(status: string | null | undefined): string {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return 'success';
|
||||
case 'pending':
|
||||
case 'processing':
|
||||
return 'processing';
|
||||
case 'failed':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -137,275 +191,203 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<UiPageHeader
|
||||
title="تلفن سازمانی"
|
||||
description="ثبت خودکار تماس، خلاصه گفتگو و اعلان تماس ورودی"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" :disabled="loading" @click="load">
|
||||
<UiIcon name="refresh" />
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">تلفن سازمانی</a-typography-title>
|
||||
<a-button :disabled="loading" @click="load">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
|
||||
|
||||
<UiCard title="اتصال جدید">
|
||||
<p class="hint">
|
||||
سانترال یا سرویس VoIP شما باید رویداد تماس را با JSON به آدرس وبهوک بفرستد.
|
||||
فیلدهای اصلی:
|
||||
<code>callId</code>،
|
||||
<code>direction</code> (inbound/outbound)،
|
||||
<code>status</code>،
|
||||
<code>from</code>،
|
||||
<code>to</code>.
|
||||
برای خلاصه خودکار یکی از
|
||||
<code>recordingUrl</code>
|
||||
(لینک فایل ضبط) یا
|
||||
<code>transcript</code>
|
||||
(متن آماده) را بفرستید؛ پس از
|
||||
<code>completed</code>
|
||||
/
|
||||
<code>answered</code>
|
||||
صف خلاصه ساخته میشود.
|
||||
امضا اختیاری با هدر
|
||||
<code>X-Voip-Signature</code>
|
||||
(HMAC-SHA256 هگز).
|
||||
</p>
|
||||
<div class="form-grid">
|
||||
<UiField label="نام">
|
||||
<UiInput v-model="form.label" placeholder="سانترال اصلی" />
|
||||
</UiField>
|
||||
<UiField label="رمز وبهوک (اختیاری)">
|
||||
<UiInput v-model="form.webhookSecret" placeholder="برای تأیید امضا" />
|
||||
</UiField>
|
||||
<UiField label="آدرس Click-to-Call (اختیاری)">
|
||||
<UiInput
|
||||
v-model="form.clickToCallUrl"
|
||||
<a-spin :spinning="loading">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-card title="اتصال جدید" :bordered="false">
|
||||
<a-typography-paragraph type="secondary">
|
||||
سانترال یا سرویس VoIP شما باید رویداد تماس را با JSON به آدرس وبهوک بفرستد. فیلدهای اصلی:
|
||||
<code>callId</code>، <code>direction</code> (inbound/outbound)، <code>status</code>،
|
||||
<code>from</code>، <code>to</code>. برای خلاصه خودکار یکی از <code>recordingUrl</code>
|
||||
(لینک فایل ضبط) یا <code>transcript</code> (متن آماده) را بفرستید؛ پس از
|
||||
<code>completed</code> / <code>answered</code> صف خلاصه ساخته میشود. امضا اختیاری با هدر
|
||||
<code>X-Voip-Signature</code> (HMAC-SHA256 هگز).
|
||||
</a-typography-paragraph>
|
||||
<a-form layout="vertical" @finish="create">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام" required>
|
||||
<a-input
|
||||
v-model:value="form.label"
|
||||
placeholder="سانترال اصلی"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="رمز وبهوک (اختیاری)">
|
||||
<a-input-password
|
||||
v-model:value="form.webhookSecret"
|
||||
dir="ltr"
|
||||
placeholder="برای تأیید امضا"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="آدرس Click-to-Call (اختیاری)">
|
||||
<a-input
|
||||
v-model:value="form.clickToCallUrl"
|
||||
dir="ltr"
|
||||
placeholder="https://pbx.example/originate?num={number}"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</UiField>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="create">ذخیره اتصال</UiButton>
|
||||
</UiCard>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ذخیره اتصال</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<UiCard title="اتصالها">
|
||||
<UiEmptyState
|
||||
<a-card title="اتصالها" :bordered="false">
|
||||
<a-flex vertical :gap="12">
|
||||
<a-empty
|
||||
v-if="!loading && !items.length"
|
||||
title="اتصالی نیست"
|
||||
description="یک اتصال برای دریافت رویداد تماس بسازید."
|
||||
description="اتصالی نیست. یک اتصال برای دریافت رویداد تماس بسازید."
|
||||
/>
|
||||
<div v-else class="list">
|
||||
<article v-for="c in items" :key="c.id" class="item">
|
||||
<div>
|
||||
<strong>{{ c.label }}</strong>
|
||||
<p class="muted">
|
||||
تماسهای ثبتشده {{ toFaDigits(c.callCount) }}
|
||||
·
|
||||
<a-card v-for="c in items" :key="c.id" type="inner">
|
||||
<a-row :gutter="[16, 12]" align="middle">
|
||||
<a-col :xs="24" :lg="18">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-text strong>{{ c.label }}</a-typography-text>
|
||||
<a-typography-text type="secondary">
|
||||
تماسهای ثبتشده {{ toFaDigits(c.callCount) }} ·
|
||||
{{
|
||||
c.lastEventAt
|
||||
? `آخرین رویداد ${formatJalaliNumeric(new Date(c.lastEventAt))}`
|
||||
: 'هنوز رویدادی نیامده'
|
||||
}}
|
||||
</p>
|
||||
<p v-if="c.clickToCallUrl" class="muted">Click-to-Call فعال</p>
|
||||
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
|
||||
</div>
|
||||
<UiButton variant="secondary" @click="remove(c.id)">حذف</UiButton>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="webhookUrl" class="webhook">
|
||||
<p class="hint">آدرس وبهوک:</p>
|
||||
<code>{{ webhookUrl }}</code>
|
||||
<UiButton variant="secondary" @click="copyWebhook">کپی</UiButton>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="آخرین تماسها">
|
||||
<UiEmptyState
|
||||
v-if="!calls.length"
|
||||
title="تماسی نیست"
|
||||
description="پس از ارسال رویداد از سانترال اینجا دیده میشوند."
|
||||
</a-typography-text>
|
||||
<a-tag v-if="c.clickToCallUrl" color="processing" :bordered="false">
|
||||
Click-to-Call فعال
|
||||
</a-tag>
|
||||
<a-alert
|
||||
v-if="c.lastError"
|
||||
type="warning"
|
||||
show-icon
|
||||
:message="c.lastError"
|
||||
/>
|
||||
<div v-else class="table-wrap">
|
||||
<div class="tc-row">
|
||||
<TableColumnsMenu table-key="voip.calls" align="end" />
|
||||
</a-flex>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="6">
|
||||
<a-button danger @click="remove(c.id)">حذف</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<a-card v-if="webhookUrl" type="inner" title="آدرس وبهوک">
|
||||
<a-flex vertical :gap="8">
|
||||
<a-typography-text dir="ltr" code>{{ webhookUrl }}</a-typography-text>
|
||||
<div>
|
||||
<a-button @click="copyWebhook">
|
||||
<template #icon><CopyOutlined /></template>
|
||||
کپی
|
||||
</a-button>
|
||||
</div>
|
||||
<table v-table-cols="'voip.calls'">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>جهت</th>
|
||||
<th>وضعیت</th>
|
||||
<th>از / به</th>
|
||||
<th>مشتری</th>
|
||||
<th>مدت</th>
|
||||
<th>خلاصه</th>
|
||||
<th>زمان</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in calls" :key="c.id">
|
||||
<td>{{ c.direction === 'outbound' ? 'خروجی' : 'ورودی' }}</td>
|
||||
<td>{{ statusLabel[c.status] ?? c.status }}</td>
|
||||
<td>{{ c.fromNumber || '—' }} → {{ c.toNumber || '—' }}</td>
|
||||
<td>{{ c.personName || '—' }}</td>
|
||||
<td>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
<a-card title="آخرین تماسها" :bordered="false">
|
||||
<a-empty
|
||||
v-if="!calls.length"
|
||||
description="تماسی نیست. پس از ارسال رویداد از سانترال اینجا دیده میشوند."
|
||||
/>
|
||||
<a-flex v-else vertical :gap="12">
|
||||
<a-flex justify="flex-end">
|
||||
<TableColumnsMenu table-key="voip.calls" align="end" />
|
||||
</a-flex>
|
||||
<a-table
|
||||
:columns="callColumns"
|
||||
:data-source="calls"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'جهت'">
|
||||
{{ (record as VoipCallLogDto).direction === 'outbound' ? 'خروجی' : 'ورودی' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'وضعیت'">
|
||||
{{
|
||||
c.durationSec != null
|
||||
? `${toFaDigits(c.durationSec)} ثانیه`
|
||||
statusLabel[(record as VoipCallLogDto).status] ??
|
||||
(record as VoipCallLogDto).status
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'از / به'">
|
||||
<span dir="ltr">
|
||||
{{ (record as VoipCallLogDto).fromNumber || '—' }} →
|
||||
{{ (record as VoipCallLogDto).toNumber || '—' }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'مشتری'">
|
||||
{{ (record as VoipCallLogDto).personName || '—' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'مدت'">
|
||||
{{
|
||||
(record as VoipCallLogDto).durationSec != null
|
||||
? `${toFaDigits((record as VoipCallLogDto).durationSec!)} ثانیه`
|
||||
: '—'
|
||||
}}
|
||||
</td>
|
||||
<td class="summary-cell">
|
||||
<span
|
||||
class="summary-badge"
|
||||
:data-status="c.summaryStatus || 'none'"
|
||||
</template>
|
||||
<template v-else-if="column.key === 'خلاصه'">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-tag
|
||||
:color="summaryColor((record as VoipCallLogDto).summaryStatus)"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ summaryLabel[c.summaryStatus] ?? c.summaryStatus ?? '—' }}
|
||||
</span>
|
||||
<p v-if="c.summary" class="summary-preview">
|
||||
{{ rewriteGregorianDates(c.summary).slice(0, 120) }}{{ c.summary.length > 120 ? '…' : '' }}
|
||||
</p>
|
||||
<p v-if="c.summaryError && c.summaryStatus === 'failed'" class="warn">
|
||||
{{ c.summaryError }}
|
||||
</p>
|
||||
<RouterLink
|
||||
v-if="noteLink(c)"
|
||||
class="note-link"
|
||||
:to="noteLink(c)!"
|
||||
{{
|
||||
summaryLabel[(record as VoipCallLogDto).summaryStatus] ??
|
||||
(record as VoipCallLogDto).summaryStatus ??
|
||||
'—'
|
||||
}}
|
||||
</a-tag>
|
||||
<a-typography-text
|
||||
v-if="(record as VoipCallLogDto).summary"
|
||||
type="secondary"
|
||||
>
|
||||
{{
|
||||
rewriteGregorianDates((record as VoipCallLogDto).summary!).slice(0, 120)
|
||||
}}{{
|
||||
(record as VoipCallLogDto).summary!.length > 120 ? '…' : ''
|
||||
}}
|
||||
</a-typography-text>
|
||||
<a-typography-text
|
||||
v-if="
|
||||
(record as VoipCallLogDto).summaryError &&
|
||||
(record as VoipCallLogDto).summaryStatus === 'failed'
|
||||
"
|
||||
type="danger"
|
||||
>
|
||||
{{ (record as VoipCallLogDto).summaryError }}
|
||||
</a-typography-text>
|
||||
<RouterLink v-if="noteLink(record as VoipCallLogDto)" :to="noteLink(record as VoipCallLogDto)!">
|
||||
مشاهده در پرونده
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td>{{ formatJalaliNumeric(new Date(c.startedAt)) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
</a-flex>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'زمان'">
|
||||
{{ formatJalaliNumeric(new Date((record as VoipCallLogDto).startedAt)) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</a-spin>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.hint,
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.hint code {
|
||||
background: #f1f5f9;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin: 0.75rem 0 1rem;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.webhook {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #f8fafc;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.webhook code {
|
||||
word-break: break-all;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.warn {
|
||||
color: #b45309;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: right;
|
||||
padding: 0.55rem;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.summary-cell {
|
||||
max-width: 16rem;
|
||||
}
|
||||
.summary-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
.summary-badge[data-status='done'] {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
.summary-badge[data-status='pending'],
|
||||
.summary-badge[data-status='processing'] {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.summary-badge[data-status='failed'] {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.summary-preview {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: #334155;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.note-link {
|
||||
display: inline-block;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
color: #0f766e;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,16 +9,41 @@ import type {
|
||||
WooCommerceOrderLinkDto,
|
||||
WooCommerceSyncResultDto,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
InputPassword as AInputPassword,
|
||||
Row as ARow,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Table as ATable,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { CopyOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
|
||||
const ORDER_COL_LABELS = [
|
||||
'سفارش ووکامرس',
|
||||
'فاکتور',
|
||||
'وضعیت',
|
||||
'مبلغ',
|
||||
'زمان',
|
||||
] as const;
|
||||
|
||||
registerTableColumns('woocommerce.orders', [...ORDER_COL_LABELS]);
|
||||
const orderTableCols = useTableColumns(() => 'woocommerce.orders');
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
@@ -44,6 +69,17 @@ const webhookUrl = computed(() => {
|
||||
return `${origin}/api/public/integrations/woocommerce/${items.value[0].id}/webhook`;
|
||||
});
|
||||
|
||||
const orderColumns = computed<TableColumnsType<WooCommerceOrderLinkDto>>(() => {
|
||||
const all: TableColumnsType<WooCommerceOrderLinkDto> = [
|
||||
{ title: 'سفارش ووکامرس', key: 'سفارش ووکامرس' },
|
||||
{ title: 'فاکتور', key: 'فاکتور' },
|
||||
{ title: 'وضعیت', key: 'وضعیت' },
|
||||
{ title: 'مبلغ', key: 'مبلغ' },
|
||||
{ title: 'زمان', key: 'زمان' },
|
||||
];
|
||||
return all.filter((col) => orderTableCols.isVisible(String(col.key ?? '')));
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -156,202 +192,173 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<UiPageHeader
|
||||
title="ووکامرس"
|
||||
description="همگامسازی سفارشهای فروشگاه با فاکتور و مشتری در CRM"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" :disabled="loading" @click="load">
|
||||
<UiIcon name="refresh" />
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">ووکامرس</a-typography-title>
|
||||
<a-button :disabled="loading" @click="load">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
|
||||
|
||||
<UiCard title="اتصال جدید">
|
||||
<p class="hint">
|
||||
در ووکامرس: ووکامرس ← تنظیمات ← پیشرفته ← REST API یک کلید بسازید.
|
||||
برای وبهوک: ووکامرس ← تنظیمات ← پیشرفته ← وبهوکها، موضوع «Order created/updated».
|
||||
</p>
|
||||
<div class="form-grid">
|
||||
<UiField label="نام">
|
||||
<UiInput v-model="form.label" placeholder="فروشگاه اصلی" />
|
||||
</UiField>
|
||||
<UiField label="آدرس فروشگاه">
|
||||
<UiInput v-model="form.storeUrl" placeholder="https://shop.example.com" />
|
||||
</UiField>
|
||||
<UiField label="Consumer Key">
|
||||
<UiInput v-model="form.consumerKey" placeholder="ck_…" />
|
||||
</UiField>
|
||||
<UiField label="Consumer Secret">
|
||||
<UiInput v-model="form.consumerSecret" placeholder="cs_…" />
|
||||
</UiField>
|
||||
<UiField label="Webhook Secret (اختیاری)">
|
||||
<UiInput v-model="form.webhookSecret" placeholder="برای تأیید امضا" />
|
||||
</UiField>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="create">ذخیره اتصال</UiButton>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="اتصالهای ذخیرهشده">
|
||||
<UiEmptyState
|
||||
v-if="!loading && !items.length"
|
||||
title="اتصالی نیست"
|
||||
description="کلید REST API فروشگاه را وارد کنید."
|
||||
<a-spin :spinning="loading">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-card title="اتصال جدید" :bordered="false">
|
||||
<a-typography-paragraph type="secondary">
|
||||
در ووکامرس: ووکامرس ← تنظیمات ← پیشرفته ← REST API یک کلید بسازید. برای وبهوک: ووکامرس ←
|
||||
تنظیمات ← پیشرفته ← وبهوکها، موضوع «Order created/updated».
|
||||
</a-typography-paragraph>
|
||||
<a-form layout="vertical" @finish="create">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام">
|
||||
<a-input v-model:value="form.label" placeholder="فروشگاه اصلی" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="آدرس فروشگاه" required>
|
||||
<a-input
|
||||
v-model:value="form.storeUrl"
|
||||
dir="ltr"
|
||||
placeholder="https://shop.example.com"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div v-else class="list">
|
||||
<article v-for="c in items" :key="c.id" class="item">
|
||||
<div>
|
||||
<strong>{{ c.label }}</strong>
|
||||
<p class="muted">{{ c.storeUrl }}</p>
|
||||
<p class="muted">
|
||||
کلید {{ c.consumerKeyMasked }}
|
||||
· سفارشهای واردشده {{ toFaDigits(c.importedOrders) }}
|
||||
·
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="Consumer Key" required>
|
||||
<a-input
|
||||
v-model:value="form.consumerKey"
|
||||
dir="ltr"
|
||||
placeholder="ck_…"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="Consumer Secret" required>
|
||||
<a-input-password
|
||||
v-model:value="form.consumerSecret"
|
||||
dir="ltr"
|
||||
placeholder="cs_…"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="Webhook Secret (اختیاری)">
|
||||
<a-input-password
|
||||
v-model:value="form.webhookSecret"
|
||||
dir="ltr"
|
||||
placeholder="برای تأیید امضا"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ذخیره اتصال</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card title="اتصالهای ذخیرهشده" :bordered="false">
|
||||
<a-flex vertical :gap="12">
|
||||
<a-empty
|
||||
v-if="!loading && !items.length"
|
||||
description="اتصالی نیست. کلید REST API فروشگاه را وارد کنید."
|
||||
/>
|
||||
<a-card v-for="c in items" :key="c.id" type="inner">
|
||||
<a-row :gutter="[16, 12]" align="top">
|
||||
<a-col :xs="24" :lg="14">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-text strong>{{ c.label }}</a-typography-text>
|
||||
<a-typography-text type="secondary" dir="ltr">{{ c.storeUrl }}</a-typography-text>
|
||||
<a-typography-text type="secondary">
|
||||
کلید
|
||||
<span dir="ltr">{{ c.consumerKeyMasked }}</span>
|
||||
· سفارشهای واردشده {{ toFaDigits(c.importedOrders) }} ·
|
||||
{{
|
||||
c.lastSyncAt
|
||||
? `آخرین همگامسازی ${formatJalaliNumeric(new Date(c.lastSyncAt))}`
|
||||
: 'هنوز همگام نشده'
|
||||
}}
|
||||
</p>
|
||||
<p v-if="c.lastError" class="warn">{{ c.lastError }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<UiButton variant="secondary" @click="test(c.id)">تست</UiButton>
|
||||
<UiButton :loading="syncing" @click="sync(c.id)">همگامسازی</UiButton>
|
||||
<UiButton variant="secondary" @click="remove(c.id)">حذف</UiButton>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="webhookUrl" class="webhook">
|
||||
<p class="hint">آدرس وبهوک این اتصال:</p>
|
||||
<code>{{ webhookUrl }}</code>
|
||||
<UiButton variant="secondary" @click="copyWebhook">کپی</UiButton>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="آخرین سفارشهای واردشده">
|
||||
<UiEmptyState
|
||||
v-if="!orders.length"
|
||||
title="سفارشی نیست"
|
||||
description="همگامسازی را بزنید یا وبهوک را وصل کنید."
|
||||
</a-typography-text>
|
||||
<a-alert
|
||||
v-if="c.lastError"
|
||||
type="warning"
|
||||
show-icon
|
||||
:message="c.lastError"
|
||||
/>
|
||||
<div v-else class="table-wrap">
|
||||
<div class="tc-row">
|
||||
<TableColumnsMenu table-key="woocommerce.orders" align="end" />
|
||||
</div>
|
||||
<table v-table-cols="'woocommerce.orders'">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>سفارش ووکامرس</th>
|
||||
<th>فاکتور</th>
|
||||
<th>وضعیت</th>
|
||||
<th>مبلغ</th>
|
||||
<th>زمان</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="o in orders" :key="o.id">
|
||||
<td>#{{ o.wooOrderNumber || o.wooOrderId }}</td>
|
||||
<td>{{ o.invoiceNumber }}</td>
|
||||
<td>{{ o.status }}</td>
|
||||
<td>{{ toFaDigits(o.total) }} {{ o.currency }}</td>
|
||||
<td>{{ formatJalaliNumeric(new Date(o.syncedAt)) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
</template>
|
||||
</a-flex>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="10">
|
||||
<a-space wrap>
|
||||
<a-button @click="test(c.id)">تست</a-button>
|
||||
<a-button type="primary" :loading="syncing" @click="sync(c.id)">
|
||||
همگامسازی
|
||||
</a-button>
|
||||
<a-button danger @click="remove(c.id)">حذف</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.hint,
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin: 0.75rem 0 1rem;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: start;
|
||||
}
|
||||
.webhook {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #f8fafc;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.webhook code {
|
||||
word-break: break-all;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.warn {
|
||||
color: #b45309;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: right;
|
||||
padding: 0.55rem;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
th {
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.form-grid,
|
||||
.item {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<a-card v-if="webhookUrl" type="inner" title="آدرس وبهوک این اتصال">
|
||||
<a-flex vertical :gap="8">
|
||||
<a-typography-text dir="ltr" code>{{ webhookUrl }}</a-typography-text>
|
||||
<div>
|
||||
<a-button @click="copyWebhook">
|
||||
<template #icon><CopyOutlined /></template>
|
||||
کپی
|
||||
</a-button>
|
||||
</div>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
<a-card title="آخرین سفارشهای واردشده" :bordered="false">
|
||||
<a-empty
|
||||
v-if="!orders.length"
|
||||
description="سفارشی نیست. همگامسازی را بزنید یا وبهوک را وصل کنید."
|
||||
/>
|
||||
<a-flex v-else vertical :gap="12">
|
||||
<a-flex justify="flex-end">
|
||||
<TableColumnsMenu table-key="woocommerce.orders" align="end" />
|
||||
</a-flex>
|
||||
<a-table
|
||||
:columns="orderColumns"
|
||||
:data-source="orders"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'سفارش ووکامرس'">
|
||||
#{{ (record as WooCommerceOrderLinkDto).wooOrderNumber || (record as WooCommerceOrderLinkDto).wooOrderId }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'فاکتور'">
|
||||
{{ (record as WooCommerceOrderLinkDto).invoiceNumber }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'وضعیت'">
|
||||
{{ (record as WooCommerceOrderLinkDto).status }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'مبلغ'">
|
||||
{{ toFaDigits((record as WooCommerceOrderLinkDto).total) }}
|
||||
{{ (record as WooCommerceOrderLinkDto).currency }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'زمان'">
|
||||
{{ formatJalaliNumeric(new Date((record as WooCommerceOrderLinkDto).syncedAt)) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</a-spin>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -136,9 +136,6 @@ const hideHeader = computed(() => route.name === 'routine-new' || route.name ===
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">کارهای روتین</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
برنامه روزانه، روتینهای تکرارشونده و پیگیری انجام کارها
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-space :size="8" wrap>
|
||||
<a-button
|
||||
|
||||
@@ -144,7 +144,6 @@ const greeting = computed(() => {
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="16">
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">کارهای امروز</a-typography-title>
|
||||
<a-typography-text type="secondary">{{ greeting }}</a-typography-text>
|
||||
<a-typography-text strong :style="{ color: 'var(--ant-color-primary)' }">
|
||||
<CalendarOutlined />
|
||||
{{ formatDayLong(data?.date ?? todayKey()) }}
|
||||
|
||||
@@ -41,7 +41,6 @@ import type {
|
||||
import VChart from 'vue-echarts';
|
||||
import { api } from '@/lib/api';
|
||||
import type { TreasuryDashboardDto } from '@mento/shared';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import {
|
||||
TREASURY_DASHBOARD_MOCK,
|
||||
isTreasuryDashboardEmpty,
|
||||
@@ -58,7 +57,7 @@ type ChartOption = ComposeOption<
|
||||
| LegendComponentOption
|
||||
>;
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
function num(value: string | number | null | undefined) {
|
||||
@@ -306,15 +305,6 @@ onMounted(async () => {
|
||||
<template>
|
||||
<div>
|
||||
<a-flex vertical :gap="token.margin">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">مدیریت مالی</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert
|
||||
v-if="usingMock"
|
||||
|
||||
Reference in New Issue
Block a user