Files
CRM_Montzar/apps/web/src/views/ActivitiesView.vue
T

1820 lines
58 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import {
Alert as AAlert,
Breadcrumb as ABreadcrumb,
BreadcrumbItem as ABreadcrumbItem,
Button as AButton,
Checkbox as ACheckbox,
Dropdown as ADropdown,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
InputSearch as AInputSearch,
Menu as AMenu,
MenuItem as AMenuItem,
Modal as AModal,
Pagination as APagination,
Segmented as ASegmented,
Select as ASelect,
Space as ASpace,
Spin as ASpin,
Table as ATable,
TabPane as ATabPane,
Tabs as ATabs,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import {
BulbOutlined,
CalendarOutlined,
CheckOutlined,
ClockCircleOutlined,
DeleteOutlined,
DownloadOutlined,
FilterOutlined,
MoreOutlined,
PlusOutlined,
ScheduleOutlined,
SettingOutlined,
SlidersOutlined,
StarOutlined,
UploadOutlined,
WarningOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import {
calendarDaysBetween,
formatClockFa,
formatJalaliDay,
startOfLocalDay,
toFaDigits,
} from '@/lib/jalali';
2026-09-19 15:30:31 +03:30
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import ActivitiesKanban from '@/components/activities/ActivitiesKanban.vue';
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import {
ACTIVITY_EXCEL_ALIASES,
ACTIVITY_EXCEL_FIELDS,
DIDAR_ACTIVITY_POSITIONAL,
looksLikeDidarActivityHeaderless,
} from '@/lib/activity-excel';
import ActivityFormView from '@/views/ActivityFormView.vue';
import type {
ActivityBoardDto,
ActivityDto,
ActivityFilter,
ActivityStageCountsDto,
ActivityStageDto,
ActivityStatus,
ActivityType,
2026-09-15 17:33:05 +03:30
ImportContactsResult,
OrgUserDto,
PaginatedResponse,
} from '@mento/shared';
2026-09-15 17:33:05 +03:30
import { brand } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { bindMenuDismiss, cardMenuStyle } from '@/lib/anchored-menu';
const { Title: ATypographyTitle, Text: ATypographyText, Link: ATypographyLink } = Typography;
const { token } = theme.useToken();
const GAP = 16;
const pageStyle = {
boxSizing: 'border-box' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: `${GAP}px`,
flex: '1 1 auto',
minHeight: 0,
minWidth: 0,
width: '100%',
height: '100%',
padding: 0,
margin: 0,
};
const activityClientReadOptions = {
positionalColumns: DIDAR_ACTIVITY_POSITIONAL,
detectHeaderless: looksLikeDidarActivityHeaderless,
};
const ACT_TABLE_KEY = 'activities';
const ACT_COLUMNS = [
{ key: 'title', label: 'عنوان فعالیت', width: 240 },
{ key: 'who', label: 'کارشناس', width: 120 },
{ key: 'customer', label: 'مشتری', width: 140 },
{ key: 'type', label: 'نوع فعالیت', width: 120 },
{ key: 'when', label: 'زمان شروع', width: 120 },
{ key: 'status', label: 'وضعیت', width: 140 },
] as const;
type ActColKey = (typeof ACT_COLUMNS)[number]['key'];
registerTableColumns(
ACT_TABLE_KEY,
ACT_COLUMNS.map((c) => c.label),
);
const actCols = useTableColumns(() => ACT_TABLE_KEY);
function colOn(key: ActColKey) {
const col = ACT_COLUMNS.find((c) => c.key === key)!;
return actCols.isVisible(col.label);
}
const selectMode = ref(false);
const selectedIds = ref<Set<string>>(new Set());
const selectedCount = computed(() => selectedIds.value.size);
/** 'all' یا شناسهٔ نوع فعالیت */
type TypeFilter = string;
type StatusFilter = 'all' | ActivityStatus;
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
type DeskScope = 'mine' | 'team';
type ViewMode = 'tabs' | 'board';
type TimeFilter = Exclude<ActivityFilter, 'done'>;
const TIME_TABS: Array<{ key: TimeFilter; label: string }> = [
{ key: 'today', label: 'امروز' },
{ key: 'overdue', label: 'عقب‌افتاده' },
{ key: 'upcoming', label: 'بعداً' },
{ key: 'all', label: 'همه زمان‌ها' },
];
const TIME_KEYS: TimeFilter[] = TIME_TABS.map((t) => t.key);
const VIEW_OPTIONS = [
{ value: 'tabs' as const, label: 'نمای تب' },
{ value: 'board' as const, label: 'نمای ستونی' },
];
const VIEW_STORAGE_KEY = 'activities-view-mode';
const DESK_TABS: Array<{ key: DeskScope; label: string }> = [
{ key: 'mine', label: 'میز من' },
{ key: 'team', label: 'همه تیم' },
];
const STATUS_FILTERS: Array<{ key: StatusFilter; label: string }> = [
{ key: 'all', label: 'همه وضعیت‌ها' },
{ key: 'open', label: 'باز / انجام‌نشده' },
{ key: 'done', label: 'انجام‌شده' },
{ key: 'canceled', label: 'لغو شده' },
];
const pageSize = usePageSize('activities', 10);
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const showDeskTabs = computed(() => canViewAllRecords(auth.user?.role));
const canManageStages = computed(() => canManageSettings(auth.user?.role));
function storedViewMode(): ViewMode {
try {
return localStorage.getItem(VIEW_STORAGE_KEY) === 'board' ? 'board' : 'tabs';
} catch {
return 'tabs';
}
}
const viewMode = computed<ViewMode>(() => {
if (route.query.view === 'board') return 'board';
if (route.query.view === 'tabs') return 'tabs';
return storedViewMode();
});
/** پیش‌فرض زمانی: در نمای تب «امروز»، در نمای ستونی «همه زمان‌ها» */
const defaultTime = computed<TimeFilter>(() => (viewMode.value === 'board' ? 'all' : 'today'));
const filter = computed<TimeFilter>(() => {
const value = typeof route.query.filter === 'string' ? route.query.filter : '';
if (TIME_KEYS.includes(value as TimeFilter)) return value as TimeFilter;
if (value === 'done') return 'all';
return defaultTime.value;
});
const stages = ref<ActivityStageDto[]>([]);
const stagesManagerOpen = ref(false);
const typesManagerOpen = ref(false);
/** گروه‌های فعالیت (مثل مسیر فروش)؛ هر گروه ستون‌های خودش را دارد */
const boards = ref<ActivityBoardDto[]>([]);
const defaultBoardId = computed(() => boards.value.find((b) => b.isDefault)?.id ?? '');
const board = computed<string>(() => {
const value = typeof route.query.board === 'string' ? route.query.board : '';
if (value && boards.value.some((b) => b.id === value)) return value;
return defaultBoardId.value;
});
const currentBoard = computed(() => boards.value.find((b) => b.id === board.value) ?? null);
const boardModal = ref<{ mode: 'create' | 'edit'; name: string } | null>(null);
const boardSaving = ref(false);
const boardDeleteOpen = ref(false);
async function onTypesChanged() {
await activityTypes.load(true);
kanbanReload.value += 1;
await refreshAll();
}
const kanbanReload = ref(0);
const stageCounts = ref<ActivityStageCountsDto>({ counts: {}, total: 0 });
const stage = computed<string>(() => {
const value = typeof route.query.stage === 'string' ? route.query.stage : 'all';
if (value === 'all' || stages.value.some((s) => s.id === value)) return value;
return 'all';
});
const desk = computed<DeskScope>(() => {
if (!showDeskTabs.value) return 'mine';
return route.query.desk === 'team' ? 'team' : 'mine';
});
const page = computed(() => {
const raw = Number(route.query.page);
return Number.isFinite(raw) && raw > 0 ? raw : 1;
});
const loading = ref(false);
const error = ref('');
const data = ref<PaginatedResponse<ActivityDto> | null>(null);
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const users = ref<OrgUserDto[]>([]);
const filterOpen = ref(false);
const ideasOpen = ref(false);
const ACTIVITY_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
const ACTIVITY_STATUSES: ActivityStatus[] = ['open', 'done', 'canceled'];
const activityTypes = useActivityTypes();
const TYPE_FILTERS = computed<Array<{ key: TypeFilter; label: string }>>(() => [
{ key: 'all', label: 'همه انواع' },
...activityTypes.types.value
.filter((t) => t.isActive || t.id === typeFilter.value)
.map((t) => ({ key: t.id, label: t.name })),
]);
/** پارامتر قدیمی ?type=call → نوع اصلیِ همان نوع پایه */
const legacyBaseType = computed<ActivityType | null>(() => {
const value = typeof route.query.type === 'string' ? route.query.type : '';
return ACTIVITY_TYPES.includes(value as ActivityType) ? (value as ActivityType) : null;
});
const typeFilter = computed<TypeFilter>(() => {
const id = typeof route.query.typeDefId === 'string' ? route.query.typeDefId.trim() : '';
if (id) return id;
if (legacyBaseType.value) return activityTypes.systemOf.value[legacyBaseType.value]?.id ?? 'all';
return 'all';
});
const statusFilter = computed<StatusFilter>(() => {
const value = typeof route.query.status === 'string' ? route.query.status : 'all';
return ACTIVITY_STATUSES.includes(value as ActivityStatus)
? (value as ActivityStatus)
: 'all';
});
const assigneeFilter = computed(() =>
typeof route.query.assigneeId === 'string' ? route.query.assigneeId : '',
);
const activityModal = ref<{
type?: ActivityType;
title?: string;
stageId?: string;
} | null>(null);
const menuId = ref<string | null>(null);
const boardMenuId = ref<string | null>(null);
const boardMenuActivity = ref<ActivityDto | null>(null);
const boardMenuStyle = ref<Record<string, string> | null>(null);
const deleteActivityIds = ref<string[]>([]);
const deleteSingleTitle = ref('');
const activityDeleting = ref(false);
2026-09-15 17:33:05 +03:30
const busy = ref(false);
const pageBusy = ref(false);
const busyMessage = ref('لطفاً صبر کنید…');
2026-09-15 17:33:05 +03:30
const importMsg = ref('');
const counts = ref<Record<TimeFilter, number>>({
today: 0,
overdue: 0,
upcoming: 0,
all: 0,
});
const statusLabel: Record<UiStatus, string> = {
done: 'انجام شد',
progress: 'در حال انجام',
planned: 'برنامه‌ریزی شده',
overdue: 'عقب‌افتاده',
canceled: 'لغو شده',
};
const items = computed(() => data.value?.items ?? []);
const total = computed(() => data.value?.total ?? 0);
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
const rangeFrom = computed(() => (page.value - 1) * pageSize.value + 1);
const stageById = computed(() => new Map(stages.value.map((s) => [s.id, s])));
const filterLabel = computed(() => {
const time = TIME_TABS.find((t) => t.key === filter.value)?.label ?? 'امروز';
const st = stage.value === 'all' ? 'همه ستون‌ها' : stageById.value.get(stage.value)?.name ?? '';
return `${st} · ${time}`;
});
/** فیلترهای مشترک لیست، شمارش‌ها و کانبان */
const sharedParams = computed<Record<string, string | undefined>>(() => ({
boardId: board.value || undefined,
filter: filter.value,
q: typeof route.query.q === 'string' && route.query.q.trim() ? route.query.q.trim() : undefined,
desk: showDeskTabs.value ? desk.value : undefined,
typeDefId: typeof route.query.typeDefId === 'string' && route.query.typeDefId.trim()
? route.query.typeDefId.trim()
: undefined,
type:
!(typeof route.query.typeDefId === 'string' && route.query.typeDefId.trim()) && legacyBaseType.value
? legacyBaseType.value
: undefined,
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
assigneeId: assigneeFilter.value || undefined,
}));
function stageDotStyle(s: ActivityStageDto) {
return { background: PIPELINE_ACCENT[s.color].solid };
}
const activeFacetCount = computed(() => {
let n = 0;
if (typeFilter.value !== 'all') n += 1;
if (statusFilter.value !== 'all') n += 1;
if (assigneeFilter.value) n += 1;
return n;
});
const filterBtnLabel = computed(() =>
activeFacetCount.value ? `فیلتر (${toFaDigits(activeFacetCount.value)})` : 'فیلتر',
);
const trulyEmpty = computed(
() =>
!loading.value &&
counts.value.all === 0 &&
stage.value === 'all' &&
!q.value.trim() &&
!activeFacetCount.value,
);
const listEmpty = computed(() => !loading.value && !items.value.length);
const deskModel = computed<DeskScope>({
get: () => desk.value,
set: (v) => setDesk(v),
});
const timeFilterModel = computed<TimeFilter>({
get: () => filter.value,
set: (v) => setFilter(v),
});
const timeSegmentOptions = computed(() =>
TIME_TABS.map((tab) => ({
value: tab.key,
label: `${tab.label} (${toFaDigits(counts.value[tab.key].toLocaleString('en-US'))})`,
})),
);
const typeSelectOptions = computed(() =>
TYPE_FILTERS.value.map((o) => ({ value: o.key, label: o.label })),
);
const statusSelectOptions = computed(() =>
STATUS_FILTERS.map((o) => ({ value: o.key, label: o.label })),
);
const assigneeSelectOptions = computed(() => [
{ value: '', label: 'همه کارشناسان' },
...users.value.map((u) => ({ value: u.id, label: u.fullName })),
]);
const boardSelectOptions = computed(() =>
boards.value.map((b) => ({ value: b.id, label: b.name })),
);
function onBoardSelectChange(v: unknown) {
if (typeof v === 'string' && v) setBoard(v);
}
function statusTagColor(s: UiStatus): string {
const map: Record<UiStatus, string> = {
done: 'success',
progress: 'processing',
planned: 'warning',
overdue: 'error',
canceled: 'default',
};
return map[s];
}
const tableColumns = computed<TableColumnsType<ActivityDto>>(() => {
const cols: TableColumnsType<ActivityDto> = [];
if (selectMode.value) {
cols.push({ title: '', key: 'select', width: 48, fixed: 'right' });
}
cols.push({
title: '#',
key: 'row',
width: 56,
customRender: ({ index }) => toFaDigits(rangeFrom.value + (index ?? 0)),
});
for (const col of ACT_COLUMNS) {
if (!colOn(col.key)) continue;
cols.push({
title: col.label,
key: col.key,
dataIndex: col.key,
width: col.width,
ellipsis: col.key === 'title',
});
}
cols.push({ title: '', key: 'actions', width: 56, fixed: 'left' });
return cols;
});
function isOverdue(item: ActivityDto) {
if (item.status !== 'open' || !item.dueAt) return false;
return startOfLocalDay(new Date(item.dueAt)).getTime() < startOfLocalDay().getTime();
}
function uiStatus(item: ActivityDto): UiStatus {
if (item.status === 'done') return 'done';
if (item.status === 'canceled') return 'canceled';
if (isOverdue(item)) return 'overdue';
if (item.dueAt && calendarDaysBetween(new Date(item.dueAt), new Date()) === 0) {
return 'progress';
}
return 'planned';
}
function clockText(item: ActivityDto) {
if (!item.dueAt) return '—';
return formatClockFa(new Date(item.dueAt));
}
function dayText(item: ActivityDto) {
if (!item.dueAt) return 'بدون موعد';
const days = calendarDaysBetween(new Date(item.dueAt), new Date());
if (days === 0) return 'امروز';
if (days === 1) return 'دیروز';
if (days === -1) return 'فردا';
return formatJalaliDay(new Date(item.dueAt));
}
function subtitle(item: ActivityDto) {
const linked = item.deal?.title || item.lead?.title || item.person?.fullName;
if (linked) return linked;
return (item.description ?? '').split('\n')[0].trim();
}
async function loadBoards() {
try {
const { data: rows } = await api.get<ActivityBoardDto[]>('/activities/boards');
boards.value = rows;
} catch {
error.value = 'بارگذاری گروه‌های فعالیت ناموفق بود.';
}
}
function activityTableRow(record: ActivityDto) {
const selected = selectedIds.value.has(record.id);
return {
onClick: () => {
if (selectMode.value) toggleSelect(record.id);
},
style: selected ? { background: token.value.colorPrimaryBg } : undefined,
};
}
function goActivityDetail(id: string) {
menuId.value = null;
void router.push({ name: 'activity-detail', params: { id } });
}
function goActivityEdit(id: string) {
menuId.value = null;
void router.push({ name: 'activity-edit', params: { id } });
}
function onPaginationChange(p: number, ps?: number) {
if (ps != null && ps !== pageSize.value) {
onPageSize(ps);
if (page.value !== 1) {
setPage(1);
return;
}
}
setPage(p);
}
function onStageTabChange(key: string | number) {
setStage(String(key));
}
function onTypeSelectChange(v: unknown) {
setTypeFilter(typeof v === 'string' && v ? v : 'all');
}
function onStatusSelectChange(v: unknown) {
setStatusFilter(typeof v === 'string' && v ? (v as StatusFilter) : 'all');
}
function onAssigneeSelectChange(v: unknown) {
setAssigneeFilter(typeof v === 'string' ? v : '');
}
function onSelectCheckboxChange(id: string, checked: boolean) {
const next = new Set(selectedIds.value);
if (checked) {
next.add(id);
selectMode.value = true;
} else {
next.delete(id);
}
selectedIds.value = next;
if (!next.size) selectMode.value = false;
}
function onRowMenuOpenChange(id: string, open: boolean) {
menuId.value = open ? id : null;
}
async function loadStages() {
try {
const { data: rows } = await api.get<ActivityStageDto[]>('/activities/stages', {
params: { boardId: board.value || undefined },
});
stages.value = rows;
} catch {
error.value = 'بارگذاری ستون‌های فعالیت ناموفق بود.';
}
}
function setBoard(next: string) {
void router.replace({
name: 'activities',
query: {
...route.query,
board: next && next !== defaultBoardId.value ? next : undefined,
stage: undefined,
page: undefined,
},
});
}
function openCreateBoard() {
boardModal.value = { mode: 'create', name: '' };
}
function openEditBoard() {
if (!currentBoard.value) return;
boardModal.value = { mode: 'edit', name: currentBoard.value.name };
}
async function saveBoard() {
const modal = boardModal.value;
const name = modal?.name.trim() ?? '';
if (!modal || name.length < 2 || boardSaving.value) return;
boardSaving.value = true;
error.value = '';
try {
if (modal.mode === 'create') {
const { data: created } = await api.post<ActivityBoardDto>('/activities/boards', { name });
await loadBoards();
boardModal.value = null;
setBoard(created.id);
} else if (currentBoard.value) {
await api.patch(`/activities/boards/${currentBoard.value.id}`, { name });
await loadBoards();
boardModal.value = null;
}
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ذخیرهٔ گروه ناموفق بود.')
: 'ذخیرهٔ گروه ناموفق بود.';
} finally {
boardSaving.value = false;
}
}
async function deleteBoard() {
const target = currentBoard.value;
if (!target || target.isDefault || boardSaving.value) return;
boardSaving.value = true;
error.value = '';
try {
await api.delete(`/activities/boards/${target.id}`);
boardDeleteOpen.value = false;
boardModal.value = null;
await loadBoards();
setBoard(defaultBoardId.value);
} catch {
error.value = 'حذف گروه ناموفق بود.';
} finally {
boardSaving.value = false;
}
}
/** شمارش تب‌های زمانی (با ستون انتخابی در نمای تب) و شمارش ستون‌ها (با زمان انتخابی) */
async function loadCounts() {
const base = sharedParams.value;
const stageParam = viewMode.value === 'tabs' ? stage.value : 'all';
try {
const [timeResults, stageRes] = await Promise.all([
Promise.all(
TIME_KEYS.map((key) =>
api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { ...base, filter: key, stageId: stageParam, limit: 1 },
}),
),
),
api.get<ActivityStageCountsDto>('/activities/stage-counts', { params: base }),
]);
const next = { ...counts.value };
TIME_KEYS.forEach((key, i) => {
next[key] = timeResults[i]?.data.total ?? 0;
});
counts.value = next;
stageCounts.value = stageRes.data;
} catch {
/* شمارش‌ها اختیاری‌اند؛ خطای اصلی را load نشان می‌دهد */
}
}
async function refreshAll() {
await Promise.all([viewMode.value === 'tabs' ? load() : Promise.resolve(), loadCounts()]);
}
async function loadUsers() {
if (!showDeskTabs.value) {
users.value = [];
return;
}
try {
const { data: rows } = await api.get<OrgUserDto[]>('/users');
users.value = rows.filter((u) => u.isActive !== false);
} catch {
users.value = [];
}
}
async function load() {
if (viewMode.value !== 'tabs') return;
loading.value = true;
error.value = '';
menuId.value = null;
try {
const { data: res } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: {
...sharedParams.value,
stageId: stage.value,
page: page.value,
limit: pageSize.value,
},
});
data.value = res;
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری فعالیت‌ها ناموفق بود.';
}
} finally {
loading.value = false;
}
}
function setDesk(next: DeskScope) {
void router.replace({
name: 'activities',
query: {
...route.query,
desk: next === 'mine' ? undefined : next,
page: undefined,
},
});
}
function setFilter(next: TimeFilter) {
filterOpen.value = false;
void router.replace({
name: 'activities',
query: {
...route.query,
filter: next === defaultTime.value ? undefined : next,
page: undefined,
},
});
}
function setStage(next: string) {
void router.replace({
name: 'activities',
query: {
...route.query,
stage: next === 'all' ? undefined : next,
page: undefined,
},
});
}
function setViewMode(next: ViewMode) {
try {
localStorage.setItem(VIEW_STORAGE_KEY, next);
} catch {
/* ignore */
}
void router.replace({
name: 'activities',
query: {
...route.query,
view: next,
filter: undefined,
page: undefined,
stage: next === 'board' ? undefined : route.query.stage,
},
});
}
function onViewSegmentChange(val: string | number) {
const next = String(val) as ViewMode;
if (next === 'board' || next === 'tabs') setViewMode(next);
}
function onStagesChanged() {
void loadStages().then(() => loadCounts());
}
function openAddInStage(stageId: string) {
activityModal.value = { stageId };
}
function onActivitySaved() {
activityModal.value = null;
kanbanReload.value += 1;
void refreshAll();
}
function setPage(next: number) {
const safe = Math.min(Math.max(1, next), pageCount.value);
void router.replace({
name: 'activities',
query: {
...route.query,
page: safe === 1 ? undefined : String(safe),
},
});
}
function onPageSize(size: number) {
pageSize.value = size;
if (page.value === 1) void load();
}
function onSearch() {
void router.replace({
name: 'activities',
query: {
...route.query,
q: q.value.trim() || undefined,
page: undefined,
},
});
}
function patchFacetQuery(patch: Record<string, string | undefined>) {
void router.replace({
name: 'activities',
query: {
...route.query,
...patch,
page: undefined,
},
});
}
function setTypeFilter(next: TypeFilter) {
patchFacetQuery({ typeDefId: next === 'all' ? undefined : next, type: undefined });
}
function setStatusFilter(next: StatusFilter) {
patchFacetQuery({ status: next === 'all' ? undefined : next });
}
function setAssigneeFilter(next: string) {
patchFacetQuery({
assigneeId: next || undefined,
desk: next && showDeskTabs.value ? 'team' : route.query.desk === 'team' ? 'team' : undefined,
});
}
function clearFacets() {
patchFacetQuery({
type: undefined,
status: undefined,
assigneeId: undefined,
});
}
async function complete(id: string) {
menuId.value = null;
boardMenuId.value = null;
boardMenuActivity.value = null;
try {
await api.patch(`/activities/${id}/complete`);
await refreshAll();
} catch {
error.value = 'ثبت انجام‌شدن ناموفق بود.';
}
}
function closePopovers() {
filterOpen.value = false;
menuId.value = null;
}
function canPromotePrior(item: ActivityDto) {
const pa = auth.user?.priorArchive;
if (!pa || pa.lens !== 'archive' || !pa.cutoffAt) return false;
if (item.promotedFromPriorAt) return false;
return new Date(item.createdAt).getTime() < new Date(pa.cutoffAt).getTime();
}
async function promotePriorActivity(id: string) {
menuId.value = null;
boardMenuId.value = null;
boardMenuActivity.value = null;
error.value = '';
try {
await api.post(`/activities/${id}/promote-from-prior`);
kanbanReload.value += 1;
await refreshAll();
} catch {
error.value = 'انتقال فعالیت به کار جاری ناموفق بود.';
}
}
const deleteConfirmCopy = computed(() => {
const n = deleteActivityIds.value.length;
if (n <= 0) return '';
if (n === 1) {
const title = deleteSingleTitle.value.trim();
return title
? `فعالیت «${title}» حذف شود؟ این کار قابل بازگشت نیست.`
: 'این فعالیت حذف شود؟ این کار قابل بازگشت نیست.';
}
return `${toFaDigits(n)} فعالیت حذف شود؟ این کار قابل بازگشت نیست.`;
});
function activityTitle(id: string) {
return items.value.find((item) => item.id === id)?.title ?? '';
}
function startSelect(id: string) {
menuId.value = null;
boardMenuId.value = null;
boardMenuActivity.value = null;
selectMode.value = true;
selectedIds.value = new Set([id]);
}
function toggleSelect(id: string) {
const next = new Set(selectedIds.value);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds.value = next;
if (!next.size) selectMode.value = false;
}
function clearSelect() {
selectMode.value = false;
selectedIds.value = new Set();
}
function askDeleteActivity(id: string, title?: string) {
menuId.value = null;
boardMenuId.value = null;
boardMenuActivity.value = null;
deleteSingleTitle.value = title?.trim() || activityTitle(id);
deleteActivityIds.value = [id];
}
function askBulkDelete() {
if (!selectedIds.value.size) return;
menuId.value = null;
boardMenuId.value = null;
boardMenuActivity.value = null;
deleteSingleTitle.value = '';
deleteActivityIds.value = [...selectedIds.value];
}
async function confirmDeleteActivities() {
const ids = deleteActivityIds.value;
if (!ids.length || activityDeleting.value) return;
activityDeleting.value = true;
error.value = '';
try {
const results = await Promise.allSettled(ids.map((id) => api.delete(`/activities/${id}`)));
const failed = results.filter((r) => r.status === 'rejected').length;
deleteActivityIds.value = [];
clearSelect();
kanbanReload.value += 1;
await refreshAll();
if (failed) {
error.value =
failed === ids.length
? 'حذف فعالیت‌ها ناموفق بود.'
: `${toFaDigits(failed)} مورد حذف نشد.`;
}
} finally {
activityDeleting.value = false;
}
}
function onBoardMenu(activity: ActivityDto) {
boardMenuId.value = boardMenuId.value === activity.id ? null : activity.id;
boardMenuActivity.value = boardMenuId.value ? activity : null;
boardMenuStyle.value = null;
if (!boardMenuId.value) return;
void nextTick(() => {
boardMenuStyle.value = cardMenuStyle(activity.id);
});
}
function onBoardMenuClick({ key }: { key: string | number }) {
const id = boardMenuId.value;
if (!id) return;
if (key === 'select') startSelect(id);
else if (key === 'view') {
boardMenuId.value = null;
boardMenuActivity.value = null;
goActivityDetail(id);
} else if (key === 'edit') {
boardMenuId.value = null;
boardMenuActivity.value = null;
goActivityEdit(id);
} else if (key === 'promote') void promotePriorActivity(id);
else if (key === 'delete') askDeleteActivity(id);
else {
boardMenuId.value = null;
boardMenuActivity.value = null;
}
}
const unbindBoardMenuDismiss = bindMenuDismiss(
() => boardMenuId.value != null,
() => {
boardMenuId.value = null;
boardMenuActivity.value = null;
},
);
2026-09-15 17:33:05 +03:30
async function exportExcel() {
busy.value = true;
pageBusy.value = true;
busyMessage.value = 'در حال تهیه خروجی اکسل…';
2026-09-15 17:33:05 +03:30
importMsg.value = '';
error.value = '';
try {
const { data } = await api.get<Blob>('/activities/export', { responseType: 'blob' });
const url = URL.createObjectURL(data);
const a = document.createElement('a');
a.href = url;
a.download = `${brand.filePrefix}-activities.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch {
error.value = 'خروجی اکسل ناموفق بود.';
} finally {
busy.value = false;
pageBusy.value = false;
2026-09-15 17:33:05 +03:30
}
}
2026-09-19 15:30:31 +03:30
function onImportStart() {
2026-09-15 17:33:05 +03:30
busy.value = true;
pageBusy.value = false;
2026-09-15 17:33:05 +03:30
importMsg.value = '';
error.value = '';
2026-09-19 15:30:31 +03:30
}
async function onImportDone(data: ImportContactsResult) {
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
}`;
if (data.errors.length) {
error.value = data.errors
.slice(0, 3)
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
2026-09-15 17:33:05 +03:30
}
kanbanReload.value += 1;
await refreshAll();
2026-09-19 15:30:31 +03:30
}
function onImportFail(message: string) {
error.value = message;
}
function onImportSettled() {
busy.value = false;
pageBusy.value = false;
2026-09-15 17:33:05 +03:30
}
watch(
() => [
route.query.filter,
route.query.q,
route.query.page,
route.query.desk,
route.query.type,
route.query.typeDefId,
route.query.status,
route.query.assigneeId,
route.query.stage,
route.query.view,
],
() => {
q.value = typeof route.query.q === 'string' ? route.query.q : '';
void load();
void loadCounts();
},
);
/** گروه عوض شد → ستون‌های همان گروه، بعد لیست و شمارش‌ها */
watch(board, (next, prev) => {
if (!prev || next === prev) return;
clearSelect();
void loadStages().then(() => {
kanbanReload.value += 1;
void load();
void loadCounts();
});
});
/** لینک‌های قدیمی ?filter=done → ستون انجام‌شده */
function migrateLegacyDoneFilter() {
if (route.query.filter !== 'done') return false;
const doneStage = stages.value.find((s) => s.kind === 'done');
void router.replace({
name: 'activities',
query: {
...route.query,
filter: 'all',
stage: doneStage?.id,
},
});
return true;
}
onMounted(() => {
void loadUsers();
void activityTypes.load(true);
void loadBoards()
.then(() => loadStages())
.then(() => {
if (migrateLegacyDoneFilter()) return;
void load();
void loadCounts();
});
});
watch(viewMode, () => {
clearSelect();
boardMenuId.value = null;
boardMenuActivity.value = null;
menuId.value = null;
});
onUnmounted(() => {
unbindBoardMenuDismiss();
});
</script>
<template>
<div class="activities-page" dir="rtl" :style="pageStyle" @click="closePopovers">
<a-flex vertical :gap="12" :style="{ flexShrink: 0 }">
<a-breadcrumb>
<a-breadcrumb-item>
<RouterLink :to="{ name: 'home' }">خانه</RouterLink>
</a-breadcrumb-item>
<a-breadcrumb-item>میز کار</a-breadcrumb-item>
</a-breadcrumb>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
<a-space :size="10" align="center">
<span
:style="{
width: `${token.controlHeight}px`,
height: `${token.controlHeight}px`,
borderRadius: `${token.borderRadius}px`,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: token.colorPrimary,
color: token.colorTextLightSolid,
flexShrink: 0,
}"
aria-hidden="true"
>
<ScheduleOutlined />
</span>
<a-typography-title :level="4" :style="{ margin: 0 }">
میز کار و فعالیت‌ها
</a-typography-title>
</a-space>
<a-space wrap :size="8">
<a-segmented
:value="viewMode"
:options="VIEW_OPTIONS"
@change="onViewSegmentChange"
/>
<a-button
:disabled="busy"
:loading="pageBusy"
@click.stop="exportExcel"
>
<template #icon><DownloadOutlined /></template>
خروجی اکسل
</a-button>
<ExcelImportFlow
preview-path="/activities/import/preview"
import-path="/activities/import"
rows-import-path="/activities/import/rows"
:client-aliases="ACTIVITY_EXCEL_ALIASES"
:client-fields="ACTIVITY_EXCEL_FIELDS"
:client-read-options="activityClientReadOptions"
:disabled="busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<template #default="{ loading: importLoading }">
<a-button :disabled="busy" :loading="importLoading">
<template #icon><UploadOutlined /></template>
ورود از اکسل
</a-button>
</template>
</ExcelImportFlow>
<RouterLink :to="{ name: 'activities-calendar' }">
<a-button>
<template #icon><CalendarOutlined /></template>
تقویم
</a-button>
</RouterLink>
<a-button type="primary" @click.stop="activityModal = {}">
<template #icon><PlusOutlined /></template>
فعالیت جدید
</a-button>
</a-space>
</a-flex>
<a-flex
wrap="wrap"
align="center"
:gap="8"
:style="{ width: '100%', minWidth: 0 }"
>
<a-select
v-if="boards.length"
:value="board || undefined"
:options="boardSelectOptions"
placeholder="گروه فعالیت"
:style="{ minWidth: '10rem' }"
@change="onBoardSelectChange"
/>
<a-dropdown v-if="canManageStages && boards.length" :trigger="['click']">
<a-button>گروه</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="openCreateBoard">گروه جدید</a-menu-item>
<a-menu-item @click="openEditBoard">ویرایش این گروه</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-segmented
v-if="showDeskTabs"
v-model:value="deskModel"
:options="DESK_TABS.map((t) => ({ value: t.key, label: t.label }))"
/>
<a-segmented v-model:value="timeFilterModel" :options="timeSegmentOptions" />
<a-button v-if="canManageStages" @click.stop="stagesManagerOpen = true">
<template #icon><SettingOutlined /></template>
مدیریت ستون‌ها
</a-button>
<a-button v-if="canManageStages" @click.stop="typesManagerOpen = true">
<template #icon><SlidersOutlined /></template>
انواع فعالیت
</a-button>
<a-input-search
v-model:value="q"
placeholder="جستجو در عنوان، مشتری، کارشناس…"
allow-clear
:style="{ flex: '1 1 220px', minWidth: '180px', maxWidth: '100%' }"
@search="onSearch"
/>
<a-dropdown
v-model:open="filterOpen"
:trigger="['click']"
@click.stop
>
<a-button :type="activeFacetCount ? 'primary' : 'default'">
<template #icon><FilterOutlined /></template>
{{ filterBtnLabel }}
</a-button>
<template #overlay>
<div
:style="{
padding: `${token.paddingSM}px`,
background: token.colorBgElevated,
borderRadius: `${token.borderRadius}px`,
boxShadow: token.boxShadowSecondary,
width: '280px',
}"
@click.stop
>
<a-form layout="vertical">
<a-form-item label="نوع کار">
<a-select
:value="typeFilter === 'all' ? undefined : typeFilter"
allow-clear
placeholder="همه انواع"
:options="typeSelectOptions"
style="width: 100%"
@change="onTypeSelectChange"
/>
</a-form-item>
<a-form-item label="وضعیت">
<a-select
:value="statusFilter === 'all' ? undefined : statusFilter"
allow-clear
placeholder="همه وضعیت‌ها"
:options="statusSelectOptions"
style="width: 100%"
@change="onStatusSelectChange"
/>
</a-form-item>
<a-form-item v-if="showDeskTabs" label="کارشناس">
<a-select
:value="assigneeFilter || undefined"
allow-clear
placeholder="همه کارشناسان"
:options="assigneeSelectOptions"
style="width: 100%"
@change="onAssigneeSelectChange"
/>
</a-form-item>
<a-button
v-if="activeFacetCount"
type="link"
danger
block
@click="clearFacets"
>
پاک کردن فیلترها
</a-button>
</a-form>
</div>
</template>
</a-dropdown>
</a-flex>
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="importMsg" type="success" show-icon :message="importMsg" />
<a-alert
v-if="selectMode"
type="info"
show-icon
:message="`${toFaDigits(selectedCount)} فعالیت انتخاب شده`"
>
<template #action>
<a-space>
<a-button
v-if="selectedCount"
type="primary"
danger
@click.stop="askBulkDelete"
>
<template #icon><DeleteOutlined /></template>
حذف انتخاب‌شده‌ها
</a-button>
<a-button @click.stop="clearSelect">انصراف از انتخاب</a-button>
</a-space>
</template>
</a-alert>
</a-flex>
<div
:style="{
position: 'relative',
flex: '1 1 auto',
minHeight: 0,
display: 'flex',
flexDirection: 'column',
}"
>
<div
v-if="pageBusy"
:style="{
position: 'absolute',
inset: 0,
zIndex: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: token.colorBgMask,
}"
>
<a-spin :tip="busyMessage" />
</div>
<ActivitiesKanban
v-if="viewMode === 'board'"
:stages="stages"
:params="sharedParams"
:can-manage="canManageStages"
:reload-key="kanbanReload"
:select-mode="selectMode"
:selected-ids="selectedIds"
@changed="loadCounts"
@stages-changed="onStagesChanged"
@add="openAddInStage"
@error="(msg: string) => (error = msg)"
@menu-deal="onBoardMenu"
@toggle-select="toggleSelect"
@bulk-delete="askBulkDelete"
/>
<template v-else>
<a-tabs
:active-key="stage"
@change="onStageTabChange"
>
<a-tab-pane key="all">
<template #tab>
<a-space :size="6">
<span>همه</span>
<a-tag>{{ toFaDigits(stageCounts.total.toLocaleString('en-US')) }}</a-tag>
</a-space>
</template>
</a-tab-pane>
<a-tab-pane v-for="s in stages" :key="s.id">
<template #tab>
<a-space :size="6">
<span
:style="{
display: 'inline-block',
width: '8px',
height: '8px',
borderRadius: '50%',
...stageDotStyle(s),
}"
aria-hidden="true"
/>
<span>{{ s.name }}</span>
<a-tag>{{ toFaDigits((stageCounts.counts[s.id] ?? 0).toLocaleString('en-US')) }}</a-tag>
</a-space>
</template>
</a-tab-pane>
</a-tabs>
<a-spin :spinning="loading">
<a-empty
v-if="listEmpty"
:description="
trulyEmpty
? 'هنوز فعالیتی ثبت نشده! اولین تماس، جلسه یا وظیفه را ثبت کنید.'
: 'فعالیتی در این فهرست نیست. فیلتر یا جستجو را عوض کنید.'
"
>
<a-space direction="vertical" align="center">
<a-button type="primary" @click="activityModal = {}">
{{ trulyEmpty ? 'اولین فعالیتت رو ثبت کن' : 'فعالیت جدید' }}
</a-button>
<a-button type="link" @click.stop="ideasOpen = !ideasOpen">
<template #icon><BulbOutlined /></template>
ایده‌هایی برای شروع
</a-button>
<a-space v-if="ideasOpen" direction="vertical">
<a-button
type="text"
@click="activityModal = { type: 'call', title: 'تماس پیگیری' }"
>
تماس پیگیری
</a-button>
<a-button
type="text"
@click="activityModal = { type: 'meeting', title: 'جلسه دمو' }"
>
جلسه دمو
</a-button>
<a-button
type="text"
@click="activityModal = { type: 'task', title: 'وظیفه یادآوری' }"
>
وظیفه یادآوری
</a-button>
</a-space>
</a-space>
</a-empty>
<template v-else>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="token.marginSM">
<a-typography-text strong>
{{ filterLabel }} — {{ toFaDigits(total.toLocaleString('en-US')) }} فعالیت
</a-typography-text>
<a-space>
<TableColumnsMenu :table-key="ACT_TABLE_KEY" align="end" />
<RouterLink :to="{ name: 'activities-calendar' }" aria-label="تقویم">
<a-button type="text">
<template #icon><CalendarOutlined /></template>
</a-button>
</RouterLink>
</a-space>
</a-flex>
<a-table
:columns="tableColumns"
:data-source="items"
:pagination="false"
:row-key="(r: ActivityDto) => r.id"
:scroll="{ x: 960 }"
:custom-row="activityTableRow"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'select'">
<a-checkbox
:checked="selectedIds.has((record as ActivityDto).id)"
@click.stop
@change="
(e) =>
onSelectCheckboxChange(
(record as ActivityDto).id,
Boolean(e?.target?.checked),
)
"
/>
</template>
<template v-else-if="column.key === 'title'">
<a-space align="start" :size="8">
<StarOutlined :style="{ color: token.colorTextQuaternary }" />
<div :style="{ minWidth: 0 }">
<a-typography-link
@click.stop="goActivityDetail((record as ActivityDto).id)"
>
{{ (record as ActivityDto).title }}
</a-typography-link>
<div v-if="subtitle(record as ActivityDto)">
<a-typography-text type="secondary">
{{ subtitle(record as ActivityDto) }}
</a-typography-text>
</div>
<a-space
v-if="(record as ActivityDto).tags?.length"
wrap
:size="4"
:style="{ marginTop: `${token.marginXXS}px` }"
>
<a-tag v-for="t in (record as ActivityDto).tags!.slice(0, 3)" :key="t">
#{{ t }}
</a-tag>
<a-tag v-if="(record as ActivityDto).tags!.length > 3">
+{{ toFaDigits((record as ActivityDto).tags!.length - 3) }}
</a-tag>
</a-space>
</div>
</a-space>
</template>
<template v-else-if="column.key === 'who'">
<a-typography-text>
{{
(record as ActivityDto).assignees?.length
? (record as ActivityDto).assignees!.map((a) => a.fullName).join('، ')
: (record as ActivityDto).assignee?.fullName || '—'
}}
</a-typography-text>
</template>
<template v-else-if="column.key === 'customer'">
<a-space direction="vertical" :size="2">
<RouterLink
v-if="(record as ActivityDto).company"
:to="{
name: 'company-detail',
params: { id: (record as ActivityDto).company!.id },
}"
@click.stop
>
{{ (record as ActivityDto).company!.name }}
</RouterLink>
<RouterLink
v-if="(record as ActivityDto).person"
:to="{
name: 'person-detail',
params: { id: (record as ActivityDto).person!.id },
}"
@click.stop
>
<a-typography-text
:type="(record as ActivityDto).company ? 'secondary' : undefined"
>
{{ (record as ActivityDto).person!.fullName }}
</a-typography-text>
</RouterLink>
<a-typography-text
v-if="!(record as ActivityDto).company && !(record as ActivityDto).person"
type="secondary"
>
—
</a-typography-text>
</a-space>
</template>
<template v-else-if="column.key === 'type'">
<a-tag :style="typeColorStyle(activityTypeView(record as ActivityDto).color)">
{{ activityTypeView(record as ActivityDto).name }}
</a-tag>
</template>
<template v-else-if="column.key === 'when'">
<a-space direction="vertical" :size="0">
<a-typography-text
:type="uiStatus(record as ActivityDto) === 'overdue' ? 'danger' : undefined"
strong
>
{{ clockText(record as ActivityDto) }}
</a-typography-text>
<a-typography-text type="secondary">
{{ dayText(record as ActivityDto) }}
</a-typography-text>
</a-space>
</template>
<template v-else-if="column.key === 'status'">
<a-space direction="vertical" :size="4">
<a-tag :color="statusTagColor(uiStatus(record as ActivityDto))">
<template #icon>
<CheckOutlined v-if="uiStatus(record as ActivityDto) === 'done'" />
<ClockCircleOutlined
v-else-if="uiStatus(record as ActivityDto) === 'progress'"
/>
<CalendarOutlined
v-else-if="uiStatus(record as ActivityDto) === 'planned'"
/>
<WarningOutlined v-else />
</template>
{{ statusLabel[uiStatus(record as ActivityDto)] }}
</a-tag>
<a-typography-text
v-if="
(record as ActivityDto).stageId &&
stageById.get((record as ActivityDto).stageId!)
"
type="secondary"
>
<a-space :size="4">
<span
:style="{
display: 'inline-block',
width: '6px',
height: '6px',
borderRadius: '50%',
...stageDotStyle(stageById.get((record as ActivityDto).stageId!)!),
}"
/>
{{ stageById.get((record as ActivityDto).stageId!)!.name }}
</a-space>
</a-typography-text>
</a-space>
</template>
<template v-else-if="column.key === 'actions'">
<a-space @click.stop>
<a-button
v-if="selectMode && selectedIds.has((record as ActivityDto).id)"
type="text"
danger
aria-label="حذف انتخاب‌شده‌ها"
@click="askBulkDelete"
>
<template #icon><DeleteOutlined /></template>
</a-button>
<a-dropdown
v-else
:open="menuId === (record as ActivityDto).id"
:trigger="['click']"
@update:open="
(v) => onRowMenuOpenChange((record as ActivityDto).id, v)
"
>
<a-button type="text" aria-label="عملیات">
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="startSelect((record as ActivityDto).id)">
انتخاب
</a-menu-item>
<a-menu-item @click="goActivityDetail((record as ActivityDto).id)">
مشاهده
</a-menu-item>
<a-menu-item @click="goActivityEdit((record as ActivityDto).id)">
ویرایش
</a-menu-item>
<a-menu-item
v-if="canPromotePrior(record as ActivityDto)"
@click="promotePriorActivity((record as ActivityDto).id)"
>
انتقال به کار جاری
</a-menu-item>
<a-menu-item
v-if="(record as ActivityDto).status === 'open'"
@click="complete((record as ActivityDto).id)"
>
انجام شد
</a-menu-item>
<a-menu-item
danger
@click="
askDeleteActivity(
(record as ActivityDto).id,
(record as ActivityDto).title,
)
"
>
حذف
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</template>
</template>
</a-table>
<a-flex
v-if="total"
justify="space-between"
align="center"
wrap="wrap"
:gap="token.marginSM"
:style="{ marginTop: `${token.marginMD}px` }"
>
<a-pagination
:current="page"
:total="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(pageCount) }}
</a-typography-text>
</a-flex>
</template>
</a-spin>
</template>
</div>
<Teleport to="body">
<div
v-if="boardMenuId"
:style="{
position: 'fixed',
zIndex: 1080,
...(boardMenuStyle ?? {}),
}"
>
<a-menu
:style="{ minWidth: '160px', boxShadow: token.boxShadowSecondary }"
@click="onBoardMenuClick"
>
<a-menu-item key="select">انتخاب</a-menu-item>
<a-menu-item key="view">مشاهده</a-menu-item>
<a-menu-item key="edit">ویرایش</a-menu-item>
<a-menu-item
v-if="boardMenuActivity && canPromotePrior(boardMenuActivity)"
key="promote"
>
انتقال به کار جاری
</a-menu-item>
<a-menu-item key="delete" danger>حذف</a-menu-item>
<a-menu-item key="close">بستن</a-menu-item>
</a-menu>
</div>
</Teleport>
<a-modal
:open="Boolean(activityModal)"
title="فعالیت جدید"
:footer="null"
destroy-on-close
width="720px"
@cancel="activityModal = null"
>
<ActivityFormView
v-if="activityModal"
embedded
:initial-type="activityModal.type"
:initial-title="activityModal.title"
:initial-stage-id="activityModal.stageId"
:initial-board-id="board || undefined"
@saved="onActivitySaved"
@cancel="activityModal = null"
/>
</a-modal>
<a-modal
:open="deleteActivityIds.length > 0"
:title="deleteActivityIds.length > 1 ? 'حذف فعالیت‌ها' : 'حذف فعالیت'"
:confirm-loading="activityDeleting"
ok-text="حذف"
ok-type="danger"
cancel-text="انصراف"
@ok="confirmDeleteActivities"
@cancel="deleteActivityIds = []"
>
<a-typography-text>{{ deleteConfirmCopy }}</a-typography-text>
</a-modal>
<ActivityStagesManager
v-if="canManageStages"
:open="stagesManagerOpen"
:stages="stages"
:board-id="board || undefined"
:board-name="boards.length > 1 ? currentBoard?.name : undefined"
@close="stagesManagerOpen = false"
@changed="onStagesChanged(); kanbanReload += 1"
/>
<a-modal
:open="Boolean(boardModal)"
:title="boardModal?.mode === 'create' ? 'گروه فعالیت جدید' : 'ویرایش گروه فعالیت'"
:confirm-loading="boardSaving"
:ok-text="boardModal?.mode === 'create' ? 'ساخت گروه' : 'ذخیره'"
cancel-text="انصراف"
:ok-button-props="{ disabled: boardSaving || (boardModal?.name.trim().length ?? 0) < 2 }"
@ok="saveBoard"
@cancel="boardModal = null"
>
<a-form v-if="boardModal" layout="vertical">
<a-form-item label="نام گروه">
<a-input
v-model:value="boardModal.name"
:maxlength="80"
placeholder="مثلاً پشتیبانی، نصب، پیگیری فروش…"
/>
</a-form-item>
<a-typography-text v-if="boardModal.mode === 'create'" type="secondary">
گروه جدید با ستون‌های پیش‌فرض ساخته می‌شود و بعد می‌توانید ستون‌هایش را تغییر دهید.
</a-typography-text>
<a-button
v-if="boardModal.mode === 'edit' && currentBoard && !currentBoard.isDefault"
danger
type="link"
:disabled="boardSaving"
@click="boardDeleteOpen = true"
>
<template #icon><DeleteOutlined /></template>
حذف این گروه
</a-button>
</a-form>
</a-modal>
<a-modal
:open="boardDeleteOpen"
title="حذف گروه فعالیت"
:confirm-loading="boardSaving"
ok-text="حذف گروه"
ok-type="danger"
cancel-text="انصراف"
@ok="deleteBoard"
@cancel="boardDeleteOpen = false"
>
<a-typography-text>
گروه «{{ currentBoard?.name }}» و ستون‌هایش حذف شود؟ فعالیت‌های این گروه حذف نمی‌شوند و به
گروه اصلی منتقل می‌شوند.
</a-typography-text>
</a-modal>
<ActivityTypesManager
v-if="canManageStages"
:open="typesManagerOpen"
@close="typesManagerOpen = false"
@changed="onTypesChanged"
/>
</div>
</template>
<style scoped>
.activities-page {
margin: 0;
width: 100%;
}
</style>