3 Commits
Author SHA1 Message Date
MahdiandCursor 754cc43c98 fix(web): drop unused viewingSelf after subtitle removal
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:05:29 +03:30
MahdiandCursor a319085dca fix(web): clear vue-tsc errors blocking push
Type attendance rows, remove unused symbols, and harden list item typing after the Ant settings migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:05:29 +03:30
MahdiandCursor cb3a488b21 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>
2026-11-04 10:05:28 +03:30
46 changed files with 6032 additions and 7689 deletions
+68 -104
View File
@@ -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"
:checked="filter.owners?.includes('self')"
@change="toggleOwner('self')"
/>
{{ ownerSelfLabel }}
</label>
<label class="chip">
<input
type="checkbox"
:checked="filter.owners?.includes('team')"
@change="toggleOwner('team')"
/>
تیم‌های عضو
</label>
</fieldset>
<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 }}
</a-checkbox>
<a-checkbox
:checked="filter.owners?.includes('team')"
:disabled="disabled"
@change="toggleOwner('team')"
>
تیم‌های عضو
</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
v-for="s in visibleSections"
:key="s"
type="button"
class="tab"
:class="{ on: active === s }"
role="tab"
:aria-selected="active === s"
@click="active = s"
>
{{ DATA_ACCESS_SECTION_LABELS[s] }}
</button>
</div>
<DataAccessEditor
:key="active"
:section="active"
:model-value="sectionValue(active)"
:groups="customerGroups"
:stages="stagesForActive"
:pipelines="pipelines"
:boards="boardsForActive"
:activity-types="typesForActive"
:import-sources="importSources"
:import-sources-ready="importSourcesReady"
:import-sources-failed="importSourcesFailed"
:disabled="disabled"
:compact="compact"
@update:model-value="setSection(active, $event)"
/>
</template>
</a-typography-text>
<a-tabs v-else v-model:activeKey="active">
<a-tab-pane
v-for="s in visibleSections"
:key="s"
:tab="DATA_ACCESS_SECTION_LABELS[s]"
>
<DataAccessEditor
:section="s"
:model-value="sectionValue(s)"
:groups="customerGroups"
:stages="s === 'leads' ? leadStages : s === 'sales' ? dealStages : s === 'activities' ? activityStages : s === 'projects' ? projectStages : []"
:pipelines="pipelines"
: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(s, $event)"
/>
</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>
+144
View File
@@ -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>
+279 -362
View File
@@ -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,19 @@ 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,
};
}
function teamMembersLabel(team: TeamDto) {
if (!team.members.length) return 'بدون عضو';
return team.members
.map((m) => `${m.fullName}${m.role === 'manager' ? ' (سرپرست)' : ''}`)
.join('، ');
}
async function saveMembers() {
@@ -358,360 +413,222 @@ 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
v-if="!loading && !teams.length"
title="تیمی تعریف نشده"
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">
{{
t.members.length
? t.members
.map((m) => `${m.fullName}${m.role === 'manager' ? ' (سرپرست)' : ''}`)
.join('، ')
: 'بدون عضو'
}}
</p>
<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>
<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"
:checked="Boolean(memberDraft[u.id])"
@change="toggleMember(u.id)"
/>
<span>{{ u.fullName }}</span>
<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>
<section v-if="showPriorArchive" class="card">
<header class="prior-head">
<h3>بایگانی اطلاعات قبل</h3>
<JalaliDateInput
:model-value="cutoffLocal"
mode="date"
placeholder="تاریخ برش"
:disabled="cutoffSaving || cutoffLoading"
@update:model-value="onCutoffChange"
<a-divider orientation="right">تیم‌ها</a-divider>
<a-spin :spinning="loading">
<a-empty
v-if="!loading && !teams.length"
description="تیمی تعریف نشده. تیم بسازید تا اعضای فروش پرونده‌های هم‌تیمی را ببینند."
/>
</header>
<p class="hint">
فقط کاربرانی که تیک می‌خورند سوئیچ کار جاری و بایگانی را می‌بینند. بقیهٔ محیط مثل قبل همهٔ
رکوردها را مطابق سطح دسترسی خودشان می‌بینند.
</p>
<div class="prior-picker">
<UiInput v-model="priorSearch" placeholder="جستجوی کاربر…" />
<select
v-model="priorPick"
: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"
:disabled="priorSavingId === u.id"
:aria-label="`حذف ${u.fullName}`"
@click="setPriorArchive(u.id, false)"
>
×
</button>
</span>
</div>
<p v-else class="hint">هنوز کاربری برای دیدن سوئیچ بایگانی انتخاب نشده است.</p>
</section>
<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 || 'بدون توضیح' }} · {{ teamMembersLabel(t) }}
</a-typography-text>
</template>
</a-list-item-meta>
<section class="card">
<h3>محدوده داده</h3>
<p class="hint">
مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید فیلتر
جدا بگذارید.
</p>
<UiEmptyState
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>
<DataAccessPolicyPanel
v-if="accessMode === 'custom'"
v-model="draftAccess"
compact
<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>
<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)"
>
{{ u.fullName }}
</a-checkbox>
<a-select
v-if="memberDraft[u.id]"
:value="memberDraft[u.id]"
: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>
<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"
placeholder="تاریخ برش"
:disabled="cutoffSaving || cutoffLoading"
@update:model-value="onCutoffChange"
/>
</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)"
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"
@close.prevent="setPriorArchive(u.id, false)"
>
{{ u.fullName }}
</a-tag>
</a-space>
<a-typography-text v-else type="secondary">
هنوز کاربری انتخاب نشده است.
</a-typography-text>
</a-flex>
</a-collapse-panel>
<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"
description="فقط نقش‌های فروش و پشتیبانی محدوده داده جدا دارند."
/>
<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-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
/>
<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,42 +420,44 @@ function removeBranch(lane: BranchLane) {
>
<PlusOutlined :style="{ fontSize: item.end ? '16px' : '14px' }" />
</button>
<div
v-if="addAt?.key === item.key"
class="picker"
@click.stop
@pointerdown.stop
>
<div class="picker-head">افزودن گره</div>
<button
v-if="addAt.allow !== 'action'"
type="button"
class="pick condition"
@click="pickCondition"
<Transition name="auto-picker">
<div
v-if="addAt?.key === item.key"
class="picker"
@click.stop
@pointerdown.stop
>
<span class="pick-ico"><FilterOutlined /></span>
<span class="pick-copy">
<strong>شرط</strong>
<small>اگر … آنگاه</small>
</span>
</button>
<template v-if="addAt.allow !== 'condition'">
<div class="picker-head">افزودن گره</div>
<button
v-for="action in AUTOMATION_ACTIONS"
:key="action"
v-if="addAt.allow !== 'action'"
type="button"
class="pick"
:class="action"
@click="pickAction(action)"
class="pick condition"
@click="pickCondition"
>
<span class="pick-ico"><component :is="actionIcon(action)" /></span>
<span class="pick-ico"><FilterOutlined /></span>
<span class="pick-copy">
<strong>{{ actionShort(action) }}</strong>
<small>{{ AUTOMATION_ACTION_LABELS[action] }}</small>
<strong>شرط</strong>
<small>اگر … آنگاه</small>
</span>
</button>
</template>
</div>
<template v-if="addAt.allow !== 'condition'">
<button
v-for="action in AUTOMATION_ACTIONS"
:key="action"
type="button"
class="pick"
:class="action"
@click="pickAction(action)"
>
<span class="pick-ico"><component :is="actionIcon(action)" /></span>
<span class="pick-copy">
<strong>{{ actionShort(action) }}</strong>
<small>{{ AUTOMATION_ACTION_LABELS[action] }}</small>
</span>
</button>
</template>
</div>
</Transition>
</div>
<AutomationNode
v-else
@@ -474,42 +501,44 @@ function removeBranch(lane: BranchLane) {
>
<PlusOutlined :style="{ fontSize: '14px' }" />
</button>
<div
v-if="addAt?.key === item.key"
class="picker"
@click.stop
@pointerdown.stop
>
<div class="picker-head">افزودن گره</div>
<button
v-if="addAt.allow !== 'action'"
type="button"
class="pick condition"
@click="pickCondition"
<Transition name="auto-picker">
<div
v-if="addAt?.key === item.key"
class="picker"
@click.stop
@pointerdown.stop
>
<span class="pick-ico"><FilterOutlined /></span>
<span class="pick-copy">
<strong>شرط</strong>
<small>اگر … آنگاه</small>
</span>
</button>
<template v-if="addAt.allow !== 'condition'">
<div class="picker-head">افزودن گره</div>
<button
v-for="action in AUTOMATION_ACTIONS"
:key="action"
v-if="addAt.allow !== 'action'"
type="button"
class="pick"
:class="action"
@click="pickAction(action)"
class="pick condition"
@click="pickCondition"
>
<span class="pick-ico"><component :is="actionIcon(action)" /></span>
<span class="pick-ico"><FilterOutlined /></span>
<span class="pick-copy">
<strong>{{ actionShort(action) }}</strong>
<small>{{ AUTOMATION_ACTION_LABELS[action] }}</small>
<strong>شرط</strong>
<small>اگر … آنگاه</small>
</span>
</button>
</template>
</div>
<template v-if="addAt.allow !== 'condition'">
<button
v-for="action in AUTOMATION_ACTIONS"
:key="action"
type="button"
class="pick"
:class="action"
@click="pickAction(action)"
>
<span class="pick-ico"><component :is="actionIcon(action)" /></span>
<span class="pick-copy">
<strong>{{ actionShort(action) }}</strong>
<small>{{ AUTOMATION_ACTION_LABELS[action] }}</small>
</span>
</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>
<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 }}» به کاریز مقصد منتقل می‌شوند.
</a-typography-text>
</a-flex>
</a-flex>
<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>
معاملات و فرم‌های سرنخ از «{{ preview.source.name }}» به کاریز مقصد منتقل می‌شوند.
</p>
</div>
</div>
<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>
<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-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>
<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-alert
v-if="selectedTarget?.isDefault"
type="info"
show-icon
message="مقصد انتخاب‌شده کاریز پیش‌فرض است."
/>
</a-flex>
<p v-if="selectedTarget?.isDefault" class="note">مقصد انتخاب‌شده کاریز پیش‌فرض است.</p>
</section>
<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>
<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-form layout="vertical">
<a-row
v-for="sourceStage in preview.stages"
:key="sourceStage.id"
:gutter="[16, 0]"
align="middle"
>
<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>
<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"
>
{{ targetStage.name }}
</option>
</UiSelect>
</article>
</div>
</section>
<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 }}» حذف می‌شود.
</a-typography-paragraph>
</a-flex>
</a-flex>
<section v-else class="step-panel">
<div class="intro">
<UiIcon name="check" :size="20" />
<div>
<h3>بازبینی نهایی</h3>
<p>
«{{ toFaDigits(preview.dealCount) }}» معامله منتقل می‌شود.
{{ toFaDigits(preview.leadForms.length) }} فرم سرنخ به مقصد وصل می‌شود.
کاریز «{{ preview.source.name }}» حذف می‌شود.
</p>
</div>
</div>
<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>
<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>
</template>
<a-typography-text v-else-if="!busy && !error" type="secondary">
<template v-if="sourceId">در حال بارگذاری پیش‌نمایش ادغام…</template>
<template v-else>برای شروع ادغام، از فهرست کاریزها دکمهٔ «ادغام» را بزنید.</template>
</a-typography-text>
</a-spin>
<p v-else-if="!busy && !error" class="muted">
<template v-if="sourceId">در حال بارگذاری پیش‌نمایش ادغام…</template>
<template v-else>برای شروع ادغام، از فهرست کاریزها دکمهٔ «ادغام» را بزنید.</template>
</p>
<a-alert v-if="error" type="error" show-icon :message="error" />
<p v-if="error" class="error">{{ error }}</p>
<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>
+1 -10
View File
@@ -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;
+93 -59
View File
@@ -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, 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>
+403 -517
View File
@@ -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 onPageSize(size: number) {
pageSize.value = size;
if (page.value === 1) 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;
}
}
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" />
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
<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>
بروزرسانی
</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>
<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>
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<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 = ''" />
<UiCard v-if="loading && !data && !deletions" title="در حال بارگذاری…">
<p class="muted">لطفاً صبر کنید.</p>
</UiCard>
<a-spin :spinning="loading">
<a-card v-if="initialLoading" :bordered="false">
<a-typography-text type="secondary">لطفاً صبر کنید.</a-typography-text>
</a-card>
<UiEmptyState
v-else-if="showingArchive && !loading && deletions && !deletions.items.length"
title="حذفی در این فهرست نیست"
description="حذف‌ها با نام انجام‌دهنده اینجا می‌مانند و تا پایان مدت نگهداری قابل بازگشت‌اند."
/>
<a-card v-else-if="showArchiveEmpty" :bordered="false">
<a-empty
description="حذفی در این فهرست نیست. حذف‌ها با نام انجام‌دهنده اینجا می‌مانند و تا پایان مدت نگهداری قابل بازگشت‌اند."
/>
</a-card>
<UiEmptyState
v-else-if="showingAttendance && !loading && attendance && !attendance.items.length"
title="ورود و خروجی ثبت نشده"
description="با ورود یا خروج کارمندان، اینجا دیده می‌شود."
/>
<a-card v-else-if="showAttendanceEmpty" :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="showAuditEmpty" :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)">
بازگردانی
</UiButton>
<UiButton size="sm" variant="danger" :disabled="busyId === item.id" @click="purgeId = item.id">
حذف کامل
</UiButton>
</td>
</tr>
</tbody>
</table>
</div>
<UiPagination
:page="page"
:total-pages="totalPages"
:total-items="deletions.total"
:page-size="pageSize"
:page-size-options="PAGE_SIZE_OPTIONS"
@update:page="onPage"
@update:page-size="onPageSize"
/>
</UiCard>
<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"
:page-size="pageSize"
:page-size-options="PAGE_SIZE_OPTIONS"
@update:page="onPage"
@update:page-size="onPageSize"
/>
</UiCard>
<UiCard v-else-if="data?.items.length" title="رویدادها">
<div class="tc-row">
<TableColumnsMenu table-key="audit-logs" align="end" />
</div>
<div class="table-wrap">
<table
v-table-cols="'audit-logs'"
data-no-rownum
:style="{ '--row-start': (page - 1) * pageSize }"
<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 }"
>
<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 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)"
>
مشاهده
</button>
</td>
</tr>
</tbody>
</table>
</div>
<UiPagination
:page="page"
:total-pages="totalPages"
:total-items="data.total"
:page-size="pageSize"
:page-size-options="PAGE_SIZE_OPTIONS"
@update:page="onPage"
@update:page-size="onPageSize"
/>
</UiCard>
بازگردانی
</a-button>
<a-button
danger
:disabled="busyId === (record as DeletionArchiveDto).id"
@click="purgeId = (record as DeletionArchiveDto).id"
>
حذف کامل
</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"
: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>
</a-card>
<UiModal :open="!!purgeItem" title="حذف کامل" @close="purgeId = null">
<p v-if="purgeItem" class="confirm">
<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"
: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>
</a-card>
<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" />
</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),
})"
>
<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)"
>
مشاهده
</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"
: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>
</a-card>
</a-spin>
<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>
</template>
</UiModal>
</div>
</a-typography-text>
</a-modal>
</a-flex>
</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>
+159 -50
View File
@@ -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,
display: 'flex',
flexDirection: 'column',
}"
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,46 +792,72 @@ watch(trigger, (next, prev) => {
@remove-range="removeRange"
@add-branch="addBranch"
/>
</div>
<aside
v-show="inspectorOpen"
:style="{
width: '320px',
flexShrink: 0,
minHeight: 0,
overflow: 'auto',
borderInlineStart: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgContainer,
padding: `${token.paddingSM}px`,
display: 'flex',
flexDirection: 'column',
gap: `${token.marginXS}px`,
}"
>
<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>
<AutomationInspector
v-model:trigger="trigger"
v-model:steps="steps"
v-model:source-id="sourceId"
v-model:chain-mode="chainMode"
:advanced="advanced"
:selected="selected"
:users="users"
:forms="forms"
:teams="teams"
:activity-type-defs="activityTypeDefs"
/>
</aside>
</a-flex>
<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',
maxWidth: `calc(100% - ${token.margin * 2}px)`,
zIndex: 10,
display: 'flex',
flexDirection: 'column',
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-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"
v-model:source-id="sourceId"
v-model:chain-mode="chainMode"
:advanced="advanced"
:selected="selected"
:users="users"
:forms="forms"
:teams="teams"
:activity-type-defs="activityTypeDefs"
/>
</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>
+1 -6
View File
@@ -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-typography-title :level="3" :style="{ margin: 0 }">چک‌ها</a-typography-title>
<a-space wrap :size="8">
<a-button type="primary" @click="openForm('in')">
<template #icon><PlusOutlined /></template>
-3
View File
@@ -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>
+52 -9
View File
@@ -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>
+125 -210
View File
@@ -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>
<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-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<UiCard v-if="!loading" class="section-card">
<form class="crm-form" @submit.prevent="save">
<UiField label="ارز پایه سیستم" hint="همه فاکتورها، پیش‌بینی‌ها و آمار کل بر پایه این واحد ارزی محاسبه خواهند شد.">
<UiSelect v-model="baseCurrency" @change="onBaseChange">
<option
v-for="c in CURRENCY_OPTIONS"
:key="c.code"
:value="c.code"
>
{{ c.labelFa }} ({{ c.code }})
</option>
</UiSelect>
</UiField>
<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 }"
>
<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">
<MoneyInput
v-model="rates[code]"
:decimals="6"
:placeholder="`نرخ برابری به ${baseCurrency}`"
<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"
/>
</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-form-item>
</a-col>
</a-row>
<div class="form-submit-row">
<UiButton type="submit" :loading="saving">
ذخیره تنظیمات ارز
</UiButton>
</div>
</form>
</UiCard>
</section>
<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"
:xs="24"
:sm="12"
:md="8"
:lg="6"
>
<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>
<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"
>
<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}`"
/>
</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>
<a-form-item>
<a-flex justify="flex-end">
<a-button type="primary" html-type="submit" :loading="saving">
ذخیره تنظیمات ارز
</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>
+167 -257
View File
@@ -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,52 @@ 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 entityLabelOf(field: CustomFieldDefinitionDto) {
return entityLabels[field.entityType];
}
function typeLabelOf(field: CustomFieldDefinitionDto) {
return typeLabels[field.fieldType];
}
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 +178,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>
<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>
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
<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-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%"
/>
</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>
</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 ? 'در حال ثبت…' : 'افزودن فیلد' }}
</a-button>
</a-col>
</a-row>
</a-form>
<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" />
{{ saving ? 'در حال ثبت…' : 'افزودن فیلد' }}
</UiButton>
</template>
</UiCard>
</form>
<a-divider orientation="right">فهرست فیلدها</a-divider>
<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-row :gutter="[16, 16]" :style="{ marginBottom: '16px' }">
<a-col :xs="24" :md="12">
<a-select
v-model:value="filter"
:options="filterOptions"
style="width: 100%"
/>
</a-col>
<a-col :xs="24" :md="8">
<a-button @click="load">
<template #icon><ReloadOutlined /></template>
بروزرسانی
</a-button>
</a-col>
</a-row>
<p v-if="error" class="error-msg">{{ error }}</p>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<a-spin :spinning="loading">
<a-empty
v-if="!loading && !visible.length"
description="فیلدی تعریف نشده است. هیچ فیلد سفارشی برای این موجودیت وجود ندارد."
/>
<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="هیچ فیلد سفارشی برای این موجودیت وجود ندارد."
/>
</div>
</section>
<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">
{{ entityLabelOf(item) }}
</a-tag>
<a-tag :bordered="false">{{ typeLabelOf(item) }}</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>
+237 -315
View File
@@ -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>
<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>
<a-tabs :active-key="tab" @change="selectTab">
<a-tab-pane v-for="item in TABS" :key="item.key" :tab="item.label" />
</a-tabs>
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<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 = ''" />
<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-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%"
/>
</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>
<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
/>
</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>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<UiEmptyState
v-else-if="tab === 'deletes' && !error && !data?.items.length"
title="حذفی ثبت نشده."
description="از این به بعد حذف‌ها با نام کاربر و زمان اینجا دیده می‌شوند."
/>
<UiEmptyState
v-else-if="tab !== 'deletes' && !error && !audit?.items.length"
:title="tab === 'creates' ? 'ایجاد جدیدی ثبت نشده.' : 'ویرایشی ثبت نشده.'"
description="با انجام کار در سامانه، نام کاربر و زمان اینجا دیده می‌شود. روی مورد بزنید تا خود پرونده باز شود."
/>
<a-spin :spinning="loading">
<a-card v-if="showDeletesEmpty" :bordered="false">
<a-empty description="حذفی ثبت نشده. از این به بعد حذف‌ها با نام کاربر و زمان اینجا دیده می‌شوند." />
</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-card v-else-if="showAuditEmpty" :bordered="false">
<a-empty
:description="
tab === 'creates'
? 'ایجاد جدیدی ثبت نشده. با انجام کار در سامانه، نام کاربر و زمان اینجا دیده می‌شود.'
: 'ویرایشی ثبت نشده. با انجام کار در سامانه، نام کاربر و زمان اینجا دیده می‌شود.'
"
/>
</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)"
>
<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-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)"
>
بازگردانی
</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"
:page-size="pageSize"
:page-size-options="PAGE_SIZE_OPTIONS"
@update:page="onPage"
@update:page-size="onPageSize"
/>
justify="space-between"
align="center"
wrap="wrap"
:gap="8"
>
<a-pagination
:current="page"
:total="listSource.total"
:page-size="pageSize"
: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>
</template>
</UiModal>
</section>
</a-typography-text>
</a-modal>
</a-flex>
</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>
+119 -204
View File
@@ -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>
<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-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 ? 'ویرایش مقاله' : 'مقاله جدید'" class="editor-card">
<form class="crm-form" @submit.prevent="save">
<UiField label="پرسش / عنوان مقاله" required>
<UiInput v-model="question" required maxlength="500" placeholder="مثلاً نحوه فعال‌سازی حساب کاربری..." />
</UiField>
<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>
<UiField label="پاسخ کامل" required>
<UiTextarea v-model="answer" :rows="4" required placeholder="توضیحات و راهنمای گام‌به‌گام..." />
</UiField>
<a-form-item label="پاسخ کامل" required>
<a-textarea
v-model:value="answer"
:rows="4"
placeholder="توضیحات و راهنمای گام‌به‌گام..."
style="width: 100%"
/>
</a-form-item>
<div class="form-grid-extra">
<UiField label="دسته‌بندی">
<UiInput v-model="category" maxlength="100" 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="ترتیب نمایش">
<UiInput v-model.number="position" type="number" min="0" />
</UiField>
<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>
<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">
{{ editingId ? 'ذخیره تغییرات' : 'افزودن مقاله' }}
</UiButton>
<UiButton v-if="editingId" variant="ghost" type="button" @click="resetForm">
انصراف
</UiButton>
</div>
</form>
</UiCard>
<UiEmptyState
v-if="!loading && !items.length"
title="هنوز مقاله‌ای ثبت نشده است"
description="با ثبت اولین سؤال و پاسخ، پایگاه دانش سازمان را تکمیل کنید."
icon="document"
/>
<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'">
{{ row.isPublished ? 'منتشرشده' : 'پیش‌نویس' }}
</UiBadge>
<UiBadge v-if="row.category" variant="info">
{{ 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>
ویرایش
</UiButton>
<UiButton variant="danger" size="sm" @click="remove(row)">
<template #icon>
<UiIcon name="trash" />
</template>
حذف
</UiButton>
</div>
</UiCard>
</div>
</section>
<a-spin :spinning="loading">
<a-empty
v-if="!loading && !items.length"
description="هنوز مقاله‌ای ثبت نشده است. با ثبت اولین سؤال و پاسخ، پایگاه دانش را تکمیل کنید."
/>
<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 ? 'منتشرشده' : 'پیش‌نویس' }}
</a-tag>
<a-tag v-if="row.category" color="processing" :bordered="false">
{{ row.category }}
</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>
ویرایش
</a-button>
<a-button danger @click="remove(row)">
<template #icon><DeleteOutlined /></template>
حذف
</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>
-8
View File
@@ -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">
-3
View File
@@ -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">
-3
View File
@@ -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 }">
-3
View File
@@ -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
+104 -193
View File
@@ -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>
فرم جدید
</UiButton>
</template>
</UiPageHeader>
<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>
فرم جدید
</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>
<a-spin :spinning="loading">
<a-card v-if="listEmpty && !error" :bordered="false">
<a-empty
description="هنوز فرمی نساخته‌اید. با ساخت اولین فرم، می‌توانید در وب‌سایت یا شبکه‌های اجتماعی سرنخ و مشتری جذب کنید."
>
<a-button type="primary" :loading="saving" @click="createForm">
<template #icon><PlusOutlined /></template>
ساخت اولین فرم
</a-button>
</a-empty>
</a-card>
<UiEmptyState
v-if="!loading && !forms.length"
title="هنوز فرمی نساخته‌اید"
description="با ساخت اولین فرم، می‌توانید در وب‌سایت یا شبکه‌های اجتماعی سرنخ و مشتری جذب کنید."
icon="document"
>
<template #action>
<UiButton :loading="saving" @click="createForm">
<template #icon>
<UiIcon name="plus" />
</template>
ساخت اولین فرم
</UiButton>
</template>
</UiEmptyState>
<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 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-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>
ویرایش فیلدها
</a-button>
</RouterLink>
<a-button @click="copyPublishLink(item)">
<template #icon><CopyOutlined /></template>
کپی لینک انتشار
</a-button>
<a-button :href="item.publicPath" target="_blank" rel="noopener">
<template #icon><EyeOutlined /></template>
پیش‌نمایش
</a-button>
<a-button danger @click="removeForm(item)">
<template #icon><DeleteOutlined /></template>
حذف
</a-button>
</a-space>
</template>
<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>
ویرایش فیلدها
</UiButton>
</RouterLink>
<UiButton variant="secondary" size="sm" @click="copyPublishLink(form)">
<template #icon>
<UiIcon name="megaphone" />
<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>
کپی لینک انتشار
</UiButton>
<a :href="form.publicPath" target="_blank" rel="noopener" class="preview-link">
<UiButton variant="ghost" size="sm">
<template #icon>
<UiIcon name="eye" />
</template>
پیش‌نمایش
</UiButton>
</a>
<UiButton variant="danger" size="sm" @click="removeForm(form)">
<template #icon>
<UiIcon name="trash" />
</template>
حذف
</UiButton>
</div>
</UiCard>
</div>
</section>
</a-list>
</a-flex>
</a-card>
</a-spin>
</a-flex>
</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>
-3
View File
@@ -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">
+1 -7
View File
@@ -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 }">
<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>
+2 -12
View File
@@ -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-typography-title :level="3" :style="{ margin: 0 }">اتوماسیون پیامک‌ها</a-typography-title>
<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-typography-title :level="3" :style="{ margin: 0 }">اتوماسیون پیامک‌ها</a-typography-title>
<a-button :disabled="loading" @click="load">
<template #icon><ReloadOutlined /></template>
بروزرسانی
+281 -335
View File
@@ -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,332 +268,251 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="page">
<UiPageHeader
title="فروشگاه‌های مونتیفای"
description="اتصال فقط‌خواندنی چند فروشگاه؛ مشتری‌ها به اشخاص وصل می‌شوند و سفارش، آدرس، تراکنش و تعاملات هر مشتری در پرونده‌اش دیده می‌شود"
>
<template #actions>
<UiButton variant="secondary" :disabled="loading" @click="load()">
<UiIcon name="refresh" />
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
<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>
بروزرسانی
</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>
<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%"
/>
</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>
<UiCard title="فروشگاه‌های متصل">
<UiEmptyState
v-if="!loading && !items.length"
title="فروشگاهی وصل نشده"
description="اطلاعات اتصال هر فروشگاه را در فرم بالا وارد کنید."
/>
<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">
{{
c.lastSyncAt
? `آخرین همگام‌سازی ${formatJalaliNumeric(new Date(c.lastSyncAt))}`
: 'هنوز همگام نشده'
}}
· وب‌هوک {{ c.webhookRegistered ? 'فعال' : 'ثبت نشده' }}
<template v-if="c.lastWebhookAt">
(آخرین رویداد {{ formatJalaliNumeric(new Date(c.lastWebhookAt)) }})
<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))}`
: 'هنوز همگام نشده'
}}
· وب‌هوک {{ c.webhookRegistered ? 'فعال' : 'ثبت نشده' }}
<template v-if="c.lastWebhookAt">
(آخرین رویداد {{ formatJalaliNumeric(new Date(c.lastWebhookAt)) }})
</template>
</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.transactionsFetched) }} تراکنش
<template v-if="c.lastSyncResult.errors.length">
· {{ toFaDigits(c.lastSyncResult.errors.length) }} خطا
</template>
</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>
</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')"
>
همگام‌سازی
</a-button>
<a-button :disabled="!c.enabled" @click="sync(c, 'full')">
همگام‌سازی کامل
</a-button>
<a-button :disabled="!c.enabled" @click="toggleWebhook(c)">
{{ c.webhookRegistered ? 'حذف وب‌هوک' : 'ثبت وب‌هوک' }}
</a-button>
<a-button @click="startEdit(c)">ویرایش</a-button>
<a-button @click="toggleEnabled(c)">
{{ c.enabled ? 'غیرفعال' : 'فعال' }}
</a-button>
<a-button danger @click="remove(c)">حذف</a-button>
</a-space>
</a-col>
</a-row>
</a-card>
</a-flex>
</a-card>
<a-card title="آخرین سفارش‌های واردشده" :bordered="false">
<a-empty
v-if="!orders.length"
description="سفارشی نیست. همگام‌سازی را بزنید یا وب‌هوک را ثبت کنید."
/>
<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>
</p>
<p v-if="c.lastSyncResult" class="muted">
{{ c.syncStatus === 'running' ? 'پیشرفت' : 'نتیجه' }}:
{{ 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 v-else-if="column.key === 'کد سفارش'">
{{
(record as MontifyOrderDto).orderReference ||
`#${(record as MontifyOrderDto).externalId}`
}}
</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 }})
</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"
:disabled="!c.enabled"
@click="sync(c, 'incremental')"
>
همگام‌سازی
</UiButton>
<UiButton
size="sm"
variant="secondary"
:disabled="!c.enabled"
@click="sync(c, 'full')"
>
همگام‌سازی کامل
</UiButton>
<UiButton size="sm" variant="secondary" :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)">
{{ c.enabled ? 'غیرفعال' : 'فعال' }}
</UiButton>
<UiButton size="sm" variant="secondary" @click="remove(c)">حذف</UiButton>
</div>
</article>
</div>
</UiCard>
<UiCard title="آخرین سفارش‌های واردشده">
<UiEmptyState
v-if="!orders.length"
title="سفارشی نیست"
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 || 'مشاهده' }}
<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>
<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>
<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>
+101 -74
View File
@@ -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-typography-title :level="3" :style="{ margin: 0 }">یادداشت‌ها</a-typography-title>
<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,72 +976,89 @@ onBeforeUnmount(() => {
</a-space>
</a-flex>
<a-flex
v-if="note.checklist?.length"
vertical
:gap="token.marginXXS"
@click.stop
>
<a-checkbox
v-for="item in note.checklist.slice(0, 6)"
:key="item.id"
:checked="item.done"
@change="toggleCardChecklist(note, item.id)"
>
<a-typography-text
:delete="item.done"
:type="item.done ? 'secondary' : undefined"
>
{{ item.text }}
</a-typography-text>
</a-checkbox>
<a-typography-text
v-if="note.checklist.length > 6"
type="secondary"
>
+{{ toFaDigits(note.checklist.length - 6) }} مورد دیگر
</a-typography-text>
</a-flex>
<a-typography-paragraph
v-else-if="note.body"
:ellipsis="{ rows: 6 }"
:style="{
marginBottom: 0,
color: token.colorText,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}"
>
{{ note.body }}
</a-typography-paragraph>
<img
v-if="firstImage(note) && blobCache[blobKey(note.id, firstImage(note)!.id)]"
:src="blobCache[blobKey(note.id, firstImage(note)!.id)]"
:alt="firstImage(note)!.fileName"
:style="{
width: '100%',
maxHeight: '140px',
objectFit: 'cover',
borderRadius: `${token.borderRadius}px`,
}"
/>
<a-tag v-if="firstPdf(note)" :bordered="false">
<FileOutlined />
{{ firstPdf(note)!.fileName }}
</a-tag>
<div
v-if="firstAudio(note) && blobCache[blobKey(note.id, firstAudio(note)!.id)]"
@click.stop
:style="{
flex: 1,
minHeight: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
gap: `${token.marginXXS}px`,
}"
>
<audio
controls
preload="metadata"
:src="blobCache[blobKey(note.id, firstAudio(note)!.id)]"
:style="{ width: '100%', height: '32px' }"
<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, viewMode === 'list' ? 6 : 4)"
:key="item.id"
:checked="item.done"
@change="toggleCardChecklist(note, item.id)"
>
<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 > (viewMode === 'list' ? 6 : 4)"
type="secondary"
>
+{{
toFaDigits(note.checklist.length - (viewMode === 'list' ? 6 : 4))
}}
مورد دیگر
</a-typography-text>
</a-flex>
<a-typography-paragraph
v-else-if="note.body"
:ellipsis="{ rows: viewMode === 'list' ? 6 : 4 }"
:style="{
marginBottom: 0,
color: token.colorText,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}"
>
{{ note.body }}
</a-typography-paragraph>
<img
v-if="firstImage(note) && blobCache[blobKey(note.id, firstImage(note)!.id)]"
:src="blobCache[blobKey(note.id, firstImage(note)!.id)]"
:alt="firstImage(note)!.fileName"
:style="{
width: '100%',
maxHeight: viewMode === 'list' ? '140px' : '72px',
objectFit: 'cover',
borderRadius: `${token.borderRadius}px`,
flexShrink: 0,
}"
/>
<a-tag v-if="firstPdf(note)" :bordered="false">
<FileOutlined />
{{ firstPdf(note)!.fileName }}
</a-tag>
<div
v-if="firstAudio(note) && blobCache[blobKey(note.id, firstAudio(note)!.id)]"
@click.stop
>
<audio
controls
preload="metadata"
:src="blobCache[blobKey(note.id, firstAudio(note)!.id)]"
:style="{ width: '100%', height: '32px' }"
/>
</div>
</div>
<a-flex
@@ -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()"
+112 -181
View File
@@ -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>
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<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 = ''" />
<form class="crm-form" @submit.prevent="createPipeline">
<UiCard>
<template #header>
<div class="card-title">
<UiIcon name="plus" :size="18" />
<h3>کاریز جدید</h3>
</div>
</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>
</template>
</UiCard>
</form>
<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>
<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-divider orientation="right">فهرست کاریزها</a-divider>
<a-spin :spinning="loading">
<a-empty
v-if="listEmpty"
description="کاریزی تعریف نشده. اولین مسیر فروش را اضافه کنید."
/>
<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>
</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-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>
</a-list-item-meta>
</a-list-item>
</template>
</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>
-3
View File
@@ -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">
+192 -284
View File
@@ -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>
<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="فوری، پشتیبانی (با کاما جدا کنید)"
/>
</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>
<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"
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-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>
<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>
<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>
<style scoped>
.page {
display: grid;
gap: 0.85rem;
max-width: 100%;
min-width: 0;
width: 100%;
}
.page.embedded {
max-width: none;
}
.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>
+287 -281
View File
@@ -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,292 +218,241 @@ 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" />
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
<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>
بروزرسانی
</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">
هدر درخواست:
<code>X-Api-Key: mk_…</code>
یا
<code>Authorization: Bearer mk_…</code>
— مسیرها:
<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
v-if="!loading && !keys.length"
title="کلیدی نیست"
description="برای دسترسی خارجی یک کلید بسازید."
/>
<div v-else class="table-wrap">
<div class="tc-row">
<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-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>
یا
<code>Authorization: Bearer mk_…</code>
— مسیرها:
<code>/api/public/v1/people</code>
و
<code>/api/public/v1/deals</code>
</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"
description="کلیدی نیست. برای دسترسی خارجی یک کلید بسازید."
/>
<template v-else-if="keys.length">
<a-flex justify="flex-end" :style="{ marginBottom: '12px' }">
<TableColumnsMenu table-key="public-api.keys" align="end" />
</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>
</a-button>
</template>
</template>
</a-table>
</template>
<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)"
<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' }"
>
{{ 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>
<template #action>
<a-button @click="copyText(createdWebhookSecret)">
<template #icon><CopyOutlined /></template>
کپی
</a-button>
</template>
</a-alert>
<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>
<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>
<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-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>
<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>
-3
View File
@@ -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">
+1 -6
View File
@@ -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-typography-title :level="3" :style="{ margin: 0 }">رزرو و نوبت‌دهی</a-typography-title>
<a-space wrap :size="8">
<a-button :disabled="loading" @click="load">
<template #icon><ReloadOutlined /></template>
-3
View File
@@ -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>
+1 -6
View File
@@ -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-typography-title :level="3" :style="{ margin: 0 }">پیامک گروهی</a-typography-title>
<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
File diff suppressed because it is too large Load Diff
+1 -6
View File
@@ -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-typography-title :level="3" :style="{ margin: 0 }">تیکت‌ها</a-typography-title>
<a-button type="primary" @click="goNew">
<template #icon><PlusOutlined /></template>
تیکت جدید
+259 -277
View File
@@ -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" />
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
<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>
بروزرسانی
</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"
placeholder="https://pbx.example/originate?num={number}"
<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%"
/>
</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="اتصالی نیست. یک اتصال برای دریافت رویداد تماس بسازید."
/>
<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))}`
: 'هنوز رویدادی نیامده'
}}
</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"
/>
</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>
</a-flex>
</a-card>
</a-flex>
</a-card>
<a-card title="آخرین تماس‌ها" :bordered="false">
<a-empty
v-if="!calls.length"
description="تماسی نیست. پس از ارسال رویداد از سانترال اینجا دیده می‌شوند."
/>
</UiField>
</div>
<UiButton :loading="saving" @click="create">ذخیره اتصال</UiButton>
</UiCard>
<UiCard title="اتصال‌ها">
<UiEmptyState
v-if="!loading && !items.length"
title="اتصالی نیست"
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) }}
·
{{
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="پس از ارسال رویداد از سانترال اینجا دیده می‌شوند."
/>
<div v-else class="table-wrap">
<div class="tc-row">
<TableColumnsMenu table-key="voip.calls" align="end" />
</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>
{{
c.durationSec != null
? `${toFaDigits(c.durationSec)} ثانیه`
: '—'
}}
</td>
<td class="summary-cell">
<span
class="summary-badge"
:data-status="c.summaryStatus || 'none'"
>
{{ 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)!"
>
مشاهده در پرونده
</RouterLink>
</td>
<td>{{ formatJalaliNumeric(new Date(c.startedAt)) }}</td>
</tr>
</tbody>
</table>
</div>
</UiCard>
</div>
<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 === 'وضعیت'">
{{
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!)} ثانیه`
: '—'
}}
</template>
<template v-else-if="column.key === 'خلاصه'">
<a-flex vertical :gap="4">
<a-tag
:color="summaryColor((record as VoipCallLogDto).summaryStatus)"
:bordered="false"
>
{{
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>
</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>
+209 -202
View File
@@ -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" />
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
<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>
بروزرسانی
</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>
<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%"
/>
</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>
<UiCard title="اتصال‌های ذخیره‌شده">
<UiEmptyState
v-if="!loading && !items.length"
title="اتصالی نیست"
description="کلید REST API فروشگاه را وارد کنید."
/>
<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) }}
·
{{
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>
<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))}`
: 'هنوز همگام نشده'
}}
</a-typography-text>
<a-alert
v-if="c.lastError"
type="warning"
show-icon
:message="c.lastError"
/>
</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>
<div v-if="webhookUrl" class="webhook">
<p class="hint">آدرس وب‌هوک این اتصال:</p>
<code>{{ webhookUrl }}</code>
<UiButton variant="secondary" @click="copyWebhook">کپی</UiButton>
</div>
</UiCard>
<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>
<UiCard title="آخرین سفارش‌های واردشده">
<UiEmptyState
v-if="!orders.length"
title="سفارشی نیست"
description="همگام‌سازی را بزنید یا وب‌هوک را وصل کنید."
/>
<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>
<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>
<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>
@@ -29,7 +29,7 @@ import {
WarningOutlined,
} from '@ant-design/icons-vue';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { Title: ATypographyTitle } = Typography;
const route = useRoute();
const router = useRouter();
@@ -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
@@ -49,8 +49,6 @@ const error = ref('');
const userId = ref('');
const filter = ref<'all' | 'open' | 'done'>('all');
const viewingSelf = computed(() => !userId.value || userId.value === auth.user?.id);
async function load(silent = false) {
if (!silent) loading.value = true;
error.value = '';
@@ -129,13 +127,6 @@ const userOptions = computed(() => [
.map((u) => ({ value: u.id, label: u.fullName })),
]);
const greeting = computed(() => {
if (!viewingSelf.value) {
const u = users.value.find((x) => x.id === userId.value);
return u ? `کارهای امروز ${u.fullName}` : 'کارهای امروز';
}
return 'امروز چه کارهایی داری؟';
});
</script>
<template>
@@ -144,7 +135,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"