2026-08-29 21:06:30 +03:30
|
|
|
|
<script setup lang="ts">
|
2026-10-17 13:26:11 +03:30
|
|
|
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
|
|
|
|
|
import axios from 'axios';
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
import { api } from '@/lib/api';
|
2026-09-02 21:29:00 +03:30
|
|
|
|
import {
|
|
|
|
|
|
calendarDaysBetween,
|
|
|
|
|
|
formatClockFa,
|
|
|
|
|
|
formatJalaliDay,
|
|
|
|
|
|
startOfLocalDay,
|
|
|
|
|
|
toFaDigits,
|
|
|
|
|
|
} from '@/lib/jalali';
|
2026-09-19 15:30:31 +03:30
|
|
|
|
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
2026-10-07 11:18:53 +03:30
|
|
|
|
import ActivitiesKanban from '@/components/activities/ActivitiesKanban.vue';
|
|
|
|
|
|
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
|
2026-10-07 12:29:23 +03:30
|
|
|
|
import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue';
|
2026-10-07 11:18:53 +03:30
|
|
|
|
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
|
2026-10-07 12:29:23 +03:30
|
|
|
|
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
2026-10-04 10:44:00 +03:30
|
|
|
|
import {
|
|
|
|
|
|
ACTIVITY_EXCEL_ALIASES,
|
|
|
|
|
|
ACTIVITY_EXCEL_FIELDS,
|
|
|
|
|
|
DIDAR_ACTIVITY_POSITIONAL,
|
|
|
|
|
|
looksLikeDidarActivityHeaderless,
|
|
|
|
|
|
} from '@/lib/activity-excel';
|
2026-09-10 01:22:24 +03:30
|
|
|
|
import ActivityFormView from '@/views/ActivityFormView.vue';
|
2026-09-02 21:29:00 +03:30
|
|
|
|
import type {
|
2026-10-20 17:23:27 +03:30
|
|
|
|
ActivityBoardDto,
|
2026-09-02 21:29:00 +03:30
|
|
|
|
ActivityDto,
|
|
|
|
|
|
ActivityFilter,
|
2026-10-07 11:18:53 +03:30
|
|
|
|
ActivityStageCountsDto,
|
|
|
|
|
|
ActivityStageDto,
|
2026-10-03 12:00:46 +03:30
|
|
|
|
ActivityStatus,
|
2026-09-02 21:29:00 +03:30
|
|
|
|
ActivityType,
|
2026-09-15 17:33:05 +03:30
|
|
|
|
ImportContactsResult,
|
2026-10-03 12:00:46 +03:30
|
|
|
|
OrgUserDto,
|
2026-09-02 21:29:00 +03:30
|
|
|
|
PaginatedResponse,
|
|
|
|
|
|
} from '@mento/shared';
|
2026-09-15 17:33:05 +03:30
|
|
|
|
import { brand } from '@mento/shared';
|
2026-09-28 12:28:14 +03:30
|
|
|
|
import { useAuthStore } from '@/stores/auth';
|
2026-10-07 11:18:53 +03:30
|
|
|
|
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
|
2026-10-05 20:00:21 +03:30
|
|
|
|
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
|
2026-10-06 13:35:13 +03:30
|
|
|
|
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
|
|
|
|
|
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
2026-10-17 12:47:07 +03:30
|
|
|
|
import { bindMenuDismiss, cardMenuStyle } from '@/lib/anchored-menu';
|
2026-10-06 13:35:13 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-06 13:35:13 +03:30
|
|
|
|
const ACT_TABLE_KEY = 'activities';
|
|
|
|
|
|
const ACT_COLUMNS = [
|
2026-11-03 16:51:51 +03:30
|
|
|
|
{ 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 },
|
2026-10-06 13:35:13 +03:30
|
|
|
|
] 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);
|
|
|
|
|
|
}
|
2026-11-03 16:51:51 +03:30
|
|
|
|
|
2026-10-17 12:47:07 +03:30
|
|
|
|
const selectMode = ref(false);
|
|
|
|
|
|
const selectedIds = ref<Set<string>>(new Set());
|
|
|
|
|
|
const selectedCount = computed(() => selectedIds.value.size);
|
2026-09-02 21:29:00 +03:30
|
|
|
|
|
2026-10-07 12:29:23 +03:30
|
|
|
|
/** 'all' یا شناسهٔ نوع فعالیت */
|
|
|
|
|
|
type TypeFilter = string;
|
2026-10-03 12:00:46 +03:30
|
|
|
|
type StatusFilter = 'all' | ActivityStatus;
|
2026-09-02 21:29:00 +03:30
|
|
|
|
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
|
2026-09-28 12:28:14 +03:30
|
|
|
|
type DeskScope = 'mine' | 'team';
|
2026-10-07 11:18:53 +03:30
|
|
|
|
type ViewMode = 'tabs' | 'board';
|
|
|
|
|
|
type TimeFilter = Exclude<ActivityFilter, 'done'>;
|
2026-09-02 21:29:00 +03:30
|
|
|
|
|
2026-10-07 11:18:53 +03:30
|
|
|
|
const TIME_TABS: Array<{ key: TimeFilter; label: string }> = [
|
2026-09-02 21:29:00 +03:30
|
|
|
|
{ key: 'today', label: 'امروز' },
|
|
|
|
|
|
{ key: 'overdue', label: 'عقبافتاده' },
|
|
|
|
|
|
{ key: 'upcoming', label: 'بعداً' },
|
2026-10-07 11:18:53 +03:30
|
|
|
|
{ key: 'all', label: 'همه زمانها' },
|
2026-09-02 21:29:00 +03:30
|
|
|
|
];
|
2026-10-07 11:18:53 +03:30
|
|
|
|
const TIME_KEYS: TimeFilter[] = TIME_TABS.map((t) => t.key);
|
|
|
|
|
|
|
|
|
|
|
|
const VIEW_OPTIONS = [
|
2026-11-03 16:51:51 +03:30
|
|
|
|
{ value: 'tabs' as const, label: 'نمای تب' },
|
|
|
|
|
|
{ value: 'board' as const, label: 'نمای ستونی' },
|
2026-10-07 11:18:53 +03:30
|
|
|
|
];
|
|
|
|
|
|
const VIEW_STORAGE_KEY = 'activities-view-mode';
|
2026-09-02 21:29:00 +03:30
|
|
|
|
|
2026-09-28 12:28:14 +03:30
|
|
|
|
const DESK_TABS: Array<{ key: DeskScope; label: string }> = [
|
|
|
|
|
|
{ key: 'mine', label: 'میز من' },
|
|
|
|
|
|
{ key: 'team', label: 'همه تیم' },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-10-03 12:00:46 +03:30
|
|
|
|
const STATUS_FILTERS: Array<{ key: StatusFilter; label: string }> = [
|
|
|
|
|
|
{ key: 'all', label: 'همه وضعیتها' },
|
|
|
|
|
|
{ key: 'open', label: 'باز / انجامنشده' },
|
|
|
|
|
|
{ key: 'done', label: 'انجامشده' },
|
|
|
|
|
|
{ key: 'canceled', label: 'لغو شده' },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-10-05 20:00:21 +03:30
|
|
|
|
const pageSize = usePageSize('activities', 10);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
|
|
|
|
|
const route = useRoute();
|
|
|
|
|
|
const router = useRouter();
|
2026-09-28 12:28:14 +03:30
|
|
|
|
const auth = useAuthStore();
|
|
|
|
|
|
const showDeskTabs = computed(() => canViewAllRecords(auth.user?.role));
|
2026-10-07 11:18:53 +03:30
|
|
|
|
const canManageStages = computed(() => canManageSettings(auth.user?.role));
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-10-07 11:18:53 +03:30
|
|
|
|
function storedViewMode(): ViewMode {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return localStorage.getItem(VIEW_STORAGE_KEY) === 'board' ? 'board' : 'tabs';
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return 'tabs';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-10-07 11:18:53 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-10-07 12:29:23 +03:30
|
|
|
|
const typesManagerOpen = ref(false);
|
|
|
|
|
|
|
2026-10-20 17:23:27 +03:30
|
|
|
|
/** گروههای فعالیت (مثل مسیر فروش)؛ هر گروه ستونهای خودش را دارد */
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
2026-10-07 12:29:23 +03:30
|
|
|
|
async function onTypesChanged() {
|
|
|
|
|
|
await activityTypes.load(true);
|
|
|
|
|
|
kanbanReload.value += 1;
|
|
|
|
|
|
await refreshAll();
|
|
|
|
|
|
}
|
2026-10-07 11:18:53 +03:30
|
|
|
|
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';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-28 12:28:14 +03:30
|
|
|
|
const desk = computed<DeskScope>(() => {
|
|
|
|
|
|
if (!showDeskTabs.value) return 'mine';
|
|
|
|
|
|
return route.query.desk === 'team' ? 'team' : 'mine';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-02 21:29:00 +03:30
|
|
|
|
const page = computed(() => {
|
|
|
|
|
|
const raw = Number(route.query.page);
|
|
|
|
|
|
return Number.isFinite(raw) && raw > 0 ? raw : 1;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
|
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 : '');
|
2026-10-03 12:00:46 +03:30
|
|
|
|
const users = ref<OrgUserDto[]>([]);
|
2026-09-02 21:29:00 +03:30
|
|
|
|
const filterOpen = ref(false);
|
|
|
|
|
|
const ideasOpen = ref(false);
|
2026-10-03 12:00:46 +03:30
|
|
|
|
|
|
|
|
|
|
const ACTIVITY_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
|
|
|
|
|
|
const ACTIVITY_STATUSES: ActivityStatus[] = ['open', 'done', 'canceled'];
|
|
|
|
|
|
|
2026-10-07 12:29:23 +03:30
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-03 12:00:46 +03:30
|
|
|
|
const typeFilter = computed<TypeFilter>(() => {
|
2026-10-07 12:29:23 +03:30
|
|
|
|
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';
|
2026-10-03 12:00:46 +03:30
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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 : '',
|
|
|
|
|
|
);
|
2026-09-10 01:22:24 +03:30
|
|
|
|
const activityModal = ref<{
|
|
|
|
|
|
type?: ActivityType;
|
|
|
|
|
|
title?: string;
|
2026-10-07 11:18:53 +03:30
|
|
|
|
stageId?: string;
|
2026-09-10 01:22:24 +03:30
|
|
|
|
} | null>(null);
|
2026-09-02 21:29:00 +03:30
|
|
|
|
const menuId = ref<string | null>(null);
|
2026-10-17 12:47:07 +03:30
|
|
|
|
const boardMenuId = ref<string | null>(null);
|
2026-10-17 17:28:57 +03:30
|
|
|
|
const boardMenuActivity = ref<ActivityDto | null>(null);
|
2026-10-17 12:47:07 +03:30
|
|
|
|
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);
|
2026-10-03 12:29:11 +03:30
|
|
|
|
const pageBusy = ref(false);
|
|
|
|
|
|
const busyMessage = ref('لطفاً صبر کنید…');
|
2026-09-15 17:33:05 +03:30
|
|
|
|
const importMsg = ref('');
|
2026-10-07 11:18:53 +03:30
|
|
|
|
const counts = ref<Record<TimeFilter, number>>({
|
2026-09-02 21:29:00 +03:30
|
|
|
|
today: 0,
|
|
|
|
|
|
overdue: 0,
|
|
|
|
|
|
upcoming: 0,
|
|
|
|
|
|
all: 0,
|
|
|
|
|
|
});
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-02 21:29:00 +03:30
|
|
|
|
const statusLabel: Record<UiStatus, string> = {
|
|
|
|
|
|
done: 'انجام شد',
|
|
|
|
|
|
progress: 'در حال انجام',
|
|
|
|
|
|
planned: 'برنامهریزی شده',
|
|
|
|
|
|
overdue: 'عقبافتاده',
|
|
|
|
|
|
canceled: 'لغو شده',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-03 12:00:46 +03:30
|
|
|
|
const items = computed(() => data.value?.items ?? []);
|
2026-09-02 21:29:00 +03:30
|
|
|
|
|
|
|
|
|
|
const total = computed(() => data.value?.total ?? 0);
|
2026-10-05 20:00:21 +03:30
|
|
|
|
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
|
2026-11-03 16:51:51 +03:30
|
|
|
|
const rangeFrom = computed(() => (page.value - 1) * pageSize.value + 1);
|
2026-10-07 11:18:53 +03:30
|
|
|
|
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>>(() => ({
|
2026-10-20 17:23:27 +03:30
|
|
|
|
boardId: board.value || undefined,
|
2026-10-07 11:18:53 +03:30
|
|
|
|
filter: filter.value,
|
|
|
|
|
|
q: typeof route.query.q === 'string' && route.query.q.trim() ? route.query.q.trim() : undefined,
|
|
|
|
|
|
desk: showDeskTabs.value ? desk.value : undefined,
|
2026-10-07 12:29:23 +03:30
|
|
|
|
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,
|
2026-10-07 11:18:53 +03:30
|
|
|
|
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
|
|
|
|
|
|
assigneeId: assigneeFilter.value || undefined,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
function stageDotStyle(s: ActivityStageDto) {
|
|
|
|
|
|
return { background: PIPELINE_ACCENT[s.color].solid };
|
|
|
|
|
|
}
|
2026-10-03 12:00:46 +03:30
|
|
|
|
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)})` : 'فیلتر',
|
|
|
|
|
|
);
|
2026-10-07 11:18:53 +03:30
|
|
|
|
const trulyEmpty = computed(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
!loading.value &&
|
|
|
|
|
|
counts.value.all === 0 &&
|
|
|
|
|
|
stage.value === 'all' &&
|
|
|
|
|
|
!q.value.trim() &&
|
|
|
|
|
|
!activeFacetCount.value,
|
|
|
|
|
|
);
|
2026-09-02 21:29:00 +03:30
|
|
|
|
const listEmpty = computed(() => !loading.value && !items.value.length);
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-02 21:29:00 +03:30
|
|
|
|
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';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-02 21:29:00 +03:30
|
|
|
|
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) {
|
2026-10-11 16:48:37 +03:30
|
|
|
|
const linked = item.deal?.title || item.lead?.title || item.person?.fullName;
|
|
|
|
|
|
if (linked) return linked;
|
|
|
|
|
|
return (item.description ?? '').split('\n')[0].trim();
|
2026-09-02 21:29:00 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-20 17:23:27 +03:30
|
|
|
|
async function loadBoards() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data: rows } = await api.get<ActivityBoardDto[]>('/activities/boards');
|
|
|
|
|
|
boards.value = rows;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
error.value = 'بارگذاری گروههای فعالیت ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 11:18:53 +03:30
|
|
|
|
async function loadStages() {
|
|
|
|
|
|
try {
|
2026-10-20 17:23:27 +03:30
|
|
|
|
const { data: rows } = await api.get<ActivityStageDto[]>('/activities/stages', {
|
|
|
|
|
|
params: { boardId: board.value || undefined },
|
|
|
|
|
|
});
|
2026-10-07 11:18:53 +03:30
|
|
|
|
stages.value = rows;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
error.value = 'بارگذاری ستونهای فعالیت ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-20 17:23:27 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 11:18:53 +03:30
|
|
|
|
/** شمارش تبهای زمانی (با ستون انتخابی در نمای تب) و شمارش ستونها (با زمان انتخابی) */
|
2026-09-02 21:29:00 +03:30
|
|
|
|
async function loadCounts() {
|
2026-10-07 11:18:53 +03:30
|
|
|
|
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()]);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 12:00:46 +03:30
|
|
|
|
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 = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
|
async function load() {
|
2026-10-07 11:18:53 +03:30
|
|
|
|
if (viewMode.value !== 'tabs') return;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
loading.value = true;
|
|
|
|
|
|
error.value = '';
|
2026-09-02 21:29:00 +03:30
|
|
|
|
menuId.value = null;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
try {
|
|
|
|
|
|
const { data: res } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
|
2026-09-02 21:29:00 +03:30
|
|
|
|
params: {
|
2026-10-07 11:18:53 +03:30
|
|
|
|
...sharedParams.value,
|
|
|
|
|
|
stageId: stage.value,
|
2026-09-02 21:29:00 +03:30
|
|
|
|
page: page.value,
|
2026-10-05 20:00:21 +03:30
|
|
|
|
limit: pageSize.value,
|
2026-09-02 21:29:00 +03:30
|
|
|
|
},
|
2026-08-29 21:06:30 +03:30
|
|
|
|
});
|
|
|
|
|
|
data.value = res;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (axios.isAxiosError(e) && !e.response) {
|
|
|
|
|
|
error.value = 'اتصال به API برقرار نشد.';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
error.value = 'بارگذاری فعالیتها ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 12:28:14 +03:30
|
|
|
|
function setDesk(next: DeskScope) {
|
|
|
|
|
|
void router.replace({
|
|
|
|
|
|
name: 'activities',
|
|
|
|
|
|
query: {
|
|
|
|
|
|
...route.query,
|
|
|
|
|
|
desk: next === 'mine' ? undefined : next,
|
|
|
|
|
|
page: undefined,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 11:18:53 +03:30
|
|
|
|
function setFilter(next: TimeFilter) {
|
2026-09-02 21:29:00 +03:30
|
|
|
|
filterOpen.value = false;
|
|
|
|
|
|
void router.replace({
|
2026-08-29 21:06:30 +03:30
|
|
|
|
name: 'activities',
|
|
|
|
|
|
query: {
|
|
|
|
|
|
...route.query,
|
2026-10-07 11:18:53 +03:30
|
|
|
|
filter: next === defaultTime.value ? undefined : next,
|
2026-09-02 21:29:00 +03:30
|
|
|
|
page: undefined,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 11:18:53 +03:30
|
|
|
|
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,
|
2026-11-03 16:51:51 +03:30
|
|
|
|
stage: next === 'board' ? undefined : route.query.stage,
|
2026-10-07 11:18:53 +03:30
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
function onViewSegmentChange(val: string | number) {
|
|
|
|
|
|
const next = String(val) as ViewMode;
|
|
|
|
|
|
if (next === 'board' || next === 'tabs') setViewMode(next);
|
|
|
|
|
|
}
|
2026-10-07 11:18:53 +03:30
|
|
|
|
|
|
|
|
|
|
function onStagesChanged() {
|
|
|
|
|
|
void loadStages().then(() => loadCounts());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openAddInStage(stageId: string) {
|
|
|
|
|
|
activityModal.value = { stageId };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onActivitySaved() {
|
|
|
|
|
|
activityModal.value = null;
|
|
|
|
|
|
kanbanReload.value += 1;
|
|
|
|
|
|
void refreshAll();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 21:29:00 +03:30
|
|
|
|
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),
|
2026-08-29 21:06:30 +03:30
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-05 20:00:21 +03:30
|
|
|
|
function onPageSize(size: number) {
|
|
|
|
|
|
pageSize.value = size;
|
|
|
|
|
|
if (page.value === 1) void load();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
|
function onSearch() {
|
2026-09-02 21:29:00 +03:30
|
|
|
|
void router.replace({
|
2026-08-29 21:06:30 +03:30
|
|
|
|
name: 'activities',
|
|
|
|
|
|
query: {
|
|
|
|
|
|
...route.query,
|
|
|
|
|
|
q: q.value.trim() || undefined,
|
2026-09-02 21:29:00 +03:30
|
|
|
|
page: undefined,
|
2026-08-29 21:06:30 +03:30
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 12:00:46 +03:30
|
|
|
|
function patchFacetQuery(patch: Record<string, string | undefined>) {
|
|
|
|
|
|
void router.replace({
|
|
|
|
|
|
name: 'activities',
|
|
|
|
|
|
query: {
|
|
|
|
|
|
...route.query,
|
|
|
|
|
|
...patch,
|
|
|
|
|
|
page: undefined,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 21:29:00 +03:30
|
|
|
|
function setTypeFilter(next: TypeFilter) {
|
2026-10-07 12:29:23 +03:30
|
|
|
|
patchFacetQuery({ typeDefId: next === 'all' ? undefined : next, type: undefined });
|
2026-10-03 12:00:46 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setStatusFilter(next: StatusFilter) {
|
2026-10-07 11:18:53 +03:30
|
|
|
|
patchFacetQuery({ status: next === 'all' ? undefined : next });
|
2026-10-03 12:00:46 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-09-02 21:29:00 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
|
async function complete(id: string) {
|
2026-09-02 21:29:00 +03:30
|
|
|
|
menuId.value = null;
|
2026-10-17 12:47:07 +03:30
|
|
|
|
boardMenuId.value = null;
|
2026-10-17 17:28:57 +03:30
|
|
|
|
boardMenuActivity.value = null;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
try {
|
|
|
|
|
|
await api.patch(`/activities/${id}/complete`);
|
2026-10-07 11:18:53 +03:30
|
|
|
|
await refreshAll();
|
2026-08-29 21:06:30 +03:30
|
|
|
|
} catch {
|
|
|
|
|
|
error.value = 'ثبت انجامشدن ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 21:29:00 +03:30
|
|
|
|
function closePopovers() {
|
|
|
|
|
|
filterOpen.value = false;
|
|
|
|
|
|
menuId.value = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-17 17:28:57 +03:30
|
|
|
|
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 = 'انتقال فعالیت به کار جاری ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-17 12:47:07 +03:30
|
|
|
|
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;
|
2026-10-17 17:28:57 +03:30
|
|
|
|
boardMenuActivity.value = null;
|
2026-10-17 12:47:07 +03:30
|
|
|
|
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;
|
2026-10-17 17:28:57 +03:30
|
|
|
|
boardMenuActivity.value = null;
|
2026-10-17 12:47:07 +03:30
|
|
|
|
deleteSingleTitle.value = title?.trim() || activityTitle(id);
|
|
|
|
|
|
deleteActivityIds.value = [id];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function askBulkDelete() {
|
|
|
|
|
|
if (!selectedIds.value.size) return;
|
|
|
|
|
|
menuId.value = null;
|
|
|
|
|
|
boardMenuId.value = null;
|
2026-10-17 17:28:57 +03:30
|
|
|
|
boardMenuActivity.value = null;
|
2026-10-17 12:47:07 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-17 17:28:57 +03:30
|
|
|
|
function onBoardMenu(activity: ActivityDto) {
|
|
|
|
|
|
boardMenuId.value = boardMenuId.value === activity.id ? null : activity.id;
|
|
|
|
|
|
boardMenuActivity.value = boardMenuId.value ? activity : null;
|
2026-10-17 13:26:11 +03:30
|
|
|
|
boardMenuStyle.value = null;
|
|
|
|
|
|
if (!boardMenuId.value) return;
|
|
|
|
|
|
void nextTick(() => {
|
2026-10-17 17:28:57 +03:30
|
|
|
|
boardMenuStyle.value = cardMenuStyle(activity.id);
|
2026-10-17 13:26:11 +03:30
|
|
|
|
});
|
2026-10-17 12:47:07 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-17 12:47:07 +03:30
|
|
|
|
const unbindBoardMenuDismiss = bindMenuDismiss(
|
|
|
|
|
|
() => boardMenuId.value != null,
|
|
|
|
|
|
() => {
|
|
|
|
|
|
boardMenuId.value = null;
|
2026-10-17 17:28:57 +03:30
|
|
|
|
boardMenuActivity.value = null;
|
2026-10-17 12:47:07 +03:30
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
async function exportExcel() {
|
|
|
|
|
|
busy.value = true;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
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;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
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;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
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
|
|
|
|
}
|
2026-10-07 11:18:53 +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;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
pageBusy.value = false;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
|
watch(
|
2026-10-03 12:00:46 +03:30
|
|
|
|
() => [
|
|
|
|
|
|
route.query.filter,
|
|
|
|
|
|
route.query.q,
|
|
|
|
|
|
route.query.page,
|
|
|
|
|
|
route.query.desk,
|
|
|
|
|
|
route.query.type,
|
2026-10-07 12:29:23 +03:30
|
|
|
|
route.query.typeDefId,
|
2026-10-03 12:00:46 +03:30
|
|
|
|
route.query.status,
|
|
|
|
|
|
route.query.assigneeId,
|
2026-10-07 11:18:53 +03:30
|
|
|
|
route.query.stage,
|
|
|
|
|
|
route.query.view,
|
2026-10-03 12:00:46 +03:30
|
|
|
|
],
|
2026-08-29 21:06:30 +03:30
|
|
|
|
() => {
|
|
|
|
|
|
q.value = typeof route.query.q === 'string' ? route.query.q : '';
|
|
|
|
|
|
void load();
|
2026-09-28 12:28:14 +03:30
|
|
|
|
void loadCounts();
|
2026-08-29 21:06:30 +03:30
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-10-20 17:23:27 +03:30
|
|
|
|
/** گروه عوض شد → ستونهای همان گروه، بعد لیست و شمارشها */
|
|
|
|
|
|
watch(board, (next, prev) => {
|
|
|
|
|
|
if (!prev || next === prev) return;
|
|
|
|
|
|
clearSelect();
|
|
|
|
|
|
void loadStages().then(() => {
|
|
|
|
|
|
kanbanReload.value += 1;
|
|
|
|
|
|
void load();
|
|
|
|
|
|
void loadCounts();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-07 11:18:53 +03:30
|
|
|
|
/** لینکهای قدیمی ?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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 21:29:00 +03:30
|
|
|
|
onMounted(() => {
|
2026-10-03 12:00:46 +03:30
|
|
|
|
void loadUsers();
|
2026-10-07 12:29:23 +03:30
|
|
|
|
void activityTypes.load(true);
|
2026-10-20 17:23:27 +03:30
|
|
|
|
void loadBoards()
|
|
|
|
|
|
.then(() => loadStages())
|
|
|
|
|
|
.then(() => {
|
|
|
|
|
|
if (migrateLegacyDoneFilter()) return;
|
|
|
|
|
|
void load();
|
|
|
|
|
|
void loadCounts();
|
|
|
|
|
|
});
|
2026-09-02 21:29:00 +03:30
|
|
|
|
});
|
2026-09-23 16:32:41 +03:30
|
|
|
|
|
2026-10-17 12:47:07 +03:30
|
|
|
|
watch(viewMode, () => {
|
|
|
|
|
|
clearSelect();
|
|
|
|
|
|
boardMenuId.value = null;
|
2026-10-17 17:28:57 +03:30
|
|
|
|
boardMenuActivity.value = null;
|
2026-10-17 12:47:07 +03:30
|
|
|
|
menuId.value = null;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-23 16:32:41 +03:30
|
|
|
|
onUnmounted(() => {
|
2026-10-17 12:47:07 +03:30
|
|
|
|
unbindBoardMenuDismiss();
|
2026-09-23 16:32:41 +03:30
|
|
|
|
});
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-02 21:29:00 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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"
|
2026-10-20 17:23:27 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<ScheduleOutlined />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<a-typography-title :level="4" :style="{ margin: 0 }">
|
|
|
|
|
|
میز کار و فعالیتها
|
|
|
|
|
|
</a-typography-title>
|
|
|
|
|
|
</a-space>
|
2026-10-07 11:18:53 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-space wrap :size="8">
|
|
|
|
|
|
<a-segmented
|
|
|
|
|
|
:value="viewMode"
|
|
|
|
|
|
:options="VIEW_OPTIONS"
|
|
|
|
|
|
@change="onViewSegmentChange"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<a-button
|
|
|
|
|
|
:disabled="busy"
|
|
|
|
|
|
:loading="pageBusy"
|
|
|
|
|
|
@click.stop="exportExcel"
|
2026-09-02 21:29:00 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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"
|
2026-10-03 12:00:46 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-10-06 13:35:13 +03:30
|
|
|
|
</RouterLink>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-button type="primary" @click.stop="activityModal = {}">
|
|
|
|
|
|
<template #icon><PlusOutlined /></template>
|
|
|
|
|
|
فعالیت جدید
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</a-space>
|
|
|
|
|
|
</a-flex>
|
2026-09-02 21:29:00 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-10-06 13:35:13 +03:30
|
|
|
|
</template>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
</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
|
2026-09-09 13:12:13 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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
|
2026-10-17 12:47:07 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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"
|
2026-09-22 11:02:08 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
پاک کردن فیلترها
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</a-form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</a-dropdown>
|
|
|
|
|
|
</a-flex>
|
2026-09-02 21:29:00 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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"
|
2026-10-17 12:47:07 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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" />
|
2026-09-02 21:29:00 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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"
|
2026-09-12 01:33:07 +03:30
|
|
|
|
/>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-09-10 01:22:24 +03:30
|
|
|
|
|
2026-10-17 13:26:11 +03:30
|
|
|
|
<Teleport to="body">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="boardMenuId"
|
2026-11-03 16:51:51 +03:30
|
|
|
|
:style="{
|
|
|
|
|
|
position: 'fixed',
|
|
|
|
|
|
zIndex: 1080,
|
|
|
|
|
|
...(boardMenuStyle ?? {}),
|
|
|
|
|
|
}"
|
2026-10-17 12:47:07 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-menu
|
|
|
|
|
|
:style="{ minWidth: '160px', boxShadow: token.boxShadowSecondary }"
|
|
|
|
|
|
@click="onBoardMenuClick"
|
2026-10-17 13:26:11 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-10-17 13:26:11 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
</Teleport>
|
2026-10-17 12:47:07 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-modal
|
2026-09-10 01:22:24 +03:30
|
|
|
|
:open="Boolean(activityModal)"
|
|
|
|
|
|
title="فعالیت جدید"
|
2026-11-03 16:51:51 +03:30
|
|
|
|
:footer="null"
|
|
|
|
|
|
destroy-on-close
|
|
|
|
|
|
width="720px"
|
|
|
|
|
|
@cancel="activityModal = null"
|
2026-09-10 01:22:24 +03:30
|
|
|
|
>
|
|
|
|
|
|
<ActivityFormView
|
|
|
|
|
|
v-if="activityModal"
|
|
|
|
|
|
embedded
|
|
|
|
|
|
:initial-type="activityModal.type"
|
|
|
|
|
|
:initial-title="activityModal.title"
|
2026-10-07 11:18:53 +03:30
|
|
|
|
:initial-stage-id="activityModal.stageId"
|
2026-10-20 17:23:27 +03:30
|
|
|
|
:initial-board-id="board || undefined"
|
2026-10-07 11:18:53 +03:30
|
|
|
|
@saved="onActivitySaved"
|
2026-09-10 01:22:24 +03:30
|
|
|
|
@cancel="activityModal = null"
|
|
|
|
|
|
/>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
</a-modal>
|
2026-10-07 11:18:53 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-modal
|
2026-10-17 12:47:07 +03:30
|
|
|
|
:open="deleteActivityIds.length > 0"
|
|
|
|
|
|
:title="deleteActivityIds.length > 1 ? 'حذف فعالیتها' : 'حذف فعالیت'"
|
2026-11-03 16:51:51 +03:30
|
|
|
|
:confirm-loading="activityDeleting"
|
|
|
|
|
|
ok-text="حذف"
|
|
|
|
|
|
ok-type="danger"
|
|
|
|
|
|
cancel-text="انصراف"
|
|
|
|
|
|
@ok="confirmDeleteActivities"
|
|
|
|
|
|
@cancel="deleteActivityIds = []"
|
2026-10-17 12:47:07 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-typography-text>{{ deleteConfirmCopy }}</a-typography-text>
|
|
|
|
|
|
</a-modal>
|
2026-10-17 12:47:07 +03:30
|
|
|
|
|
2026-10-07 11:18:53 +03:30
|
|
|
|
<ActivityStagesManager
|
|
|
|
|
|
v-if="canManageStages"
|
|
|
|
|
|
:open="stagesManagerOpen"
|
|
|
|
|
|
:stages="stages"
|
2026-10-20 17:23:27 +03:30
|
|
|
|
:board-id="board || undefined"
|
|
|
|
|
|
:board-name="boards.length > 1 ? currentBoard?.name : undefined"
|
2026-10-07 11:18:53 +03:30
|
|
|
|
@close="stagesManagerOpen = false"
|
|
|
|
|
|
@changed="onStagesChanged(); kanbanReload += 1"
|
|
|
|
|
|
/>
|
2026-10-20 17:23:27 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-modal
|
2026-10-20 17:23:27 +03:30
|
|
|
|
:open="Boolean(boardModal)"
|
|
|
|
|
|
:title="boardModal?.mode === 'create' ? 'گروه فعالیت جدید' : 'ویرایش گروه فعالیت'"
|
2026-11-03 16:51:51 +03:30
|
|
|
|
: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"
|
2026-10-20 17:23:27 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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">
|
2026-10-20 17:23:27 +03:30
|
|
|
|
گروه جدید با ستونهای پیشفرض ساخته میشود و بعد میتوانید ستونهایش را تغییر دهید.
|
2026-11-03 16:51:51 +03:30
|
|
|
|
</a-typography-text>
|
|
|
|
|
|
<a-button
|
2026-10-20 17:23:27 +03:30
|
|
|
|
v-if="boardModal.mode === 'edit' && currentBoard && !currentBoard.isDefault"
|
2026-11-03 16:51:51 +03:30
|
|
|
|
danger
|
|
|
|
|
|
type="link"
|
|
|
|
|
|
:disabled="boardSaving"
|
2026-10-20 17:23:27 +03:30
|
|
|
|
@click="boardDeleteOpen = true"
|
|
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<template #icon><DeleteOutlined /></template>
|
2026-10-20 17:23:27 +03:30
|
|
|
|
حذف این گروه
|
2026-11-03 16:51:51 +03:30
|
|
|
|
</a-button>
|
|
|
|
|
|
</a-form>
|
|
|
|
|
|
</a-modal>
|
2026-10-20 17:23:27 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-modal
|
2026-10-20 17:23:27 +03:30
|
|
|
|
:open="boardDeleteOpen"
|
|
|
|
|
|
title="حذف گروه فعالیت"
|
2026-11-03 16:51:51 +03:30
|
|
|
|
:confirm-loading="boardSaving"
|
|
|
|
|
|
ok-text="حذف گروه"
|
|
|
|
|
|
ok-type="danger"
|
|
|
|
|
|
cancel-text="انصراف"
|
|
|
|
|
|
@ok="deleteBoard"
|
|
|
|
|
|
@cancel="boardDeleteOpen = false"
|
2026-10-20 17:23:27 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-typography-text>
|
2026-10-20 17:23:27 +03:30
|
|
|
|
گروه «{{ currentBoard?.name }}» و ستونهایش حذف شود؟ فعالیتهای این گروه حذف نمیشوند و به
|
|
|
|
|
|
گروه اصلی منتقل میشوند.
|
2026-11-03 16:51:51 +03:30
|
|
|
|
</a-typography-text>
|
|
|
|
|
|
</a-modal>
|
2026-10-07 12:29:23 +03:30
|
|
|
|
<ActivityTypesManager
|
|
|
|
|
|
v-if="canManageStages"
|
|
|
|
|
|
:open="typesManagerOpen"
|
|
|
|
|
|
@close="typesManagerOpen = false"
|
|
|
|
|
|
@changed="onTypesChanged"
|
|
|
|
|
|
/>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
</div>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
.activities-page {
|
2026-08-29 21:06:30 +03:30
|
|
|
|
margin: 0;
|
2026-09-02 21:29:00 +03:30
|
|
|
|
width: 100%;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
</style>
|