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

2864 lines
75 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 { api } from '@/lib/api';
import {
calendarDaysBetween,
formatClockFa,
formatJalaliDay,
startOfLocalDay,
toFaDigits,
} from '@/lib/jalali';
import {
UiBusyOverlay,
UiButton,
UiDropdownMenu,
UiInput,
UiModal,
UiPageHeader,
UiPagination,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import UiSegmented from '@/components/ui/UiSegmented.vue';
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';
const activityClientReadOptions = {
positionalColumns: DIDAR_ACTIVITY_POSITIONAL,
detectHeaderless: looksLikeDidarActivityHeaderless,
};
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 ACT_TABLE_KEY = 'activities';
const ACT_COLUMNS = [
{ key: 'title', label: 'عنوان فعالیت', width: 'minmax(0, 2fr)' },
{ key: 'who', label: 'کارشناس', width: 'minmax(7rem, 0.9fr)' },
{ key: 'customer', label: 'مشتری', width: 'minmax(7rem, 1fr)' },
{ key: 'type', label: 'نوع فعالیت', width: '7.6rem' },
{ key: 'when', label: 'زمان شروع', width: '7.2rem' },
{ key: 'status', label: 'وضعیت', width: '8.6rem' },
] 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);
const actGridStyle = computed(() => ({
gridTemplateColumns: [
'2.4rem',
...(selectMode.value ? ['2rem'] : []),
...ACT_COLUMNS.filter((c) => actCols.isVisible(c.label)).map((c) => c.width),
'2.4rem',
].join(' '),
}));
/** '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: 'نمای تب', icon: 'list' as const },
{ value: 'board' as const, label: 'نمای ستونی', icon: 'grid' as const },
];
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 boardMenuOpen = ref(false);
const boardModal = ref<{ mode: 'create' | 'edit'; name: string; memberIds: string[] } | null>(null);
const boardMemberQuery = ref('');
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 statusIcon: Record<UiStatus, 'check' | 'clock' | 'calendar' | 'warning'> = {
done: 'check',
progress: 'clock',
planned: 'calendar',
overdue: 'warning',
canceled: 'warning',
};
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 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);
function isOverdue(item: ActivityDto) {
if (item.status !== 'open' || !item.dueAt) return false;
return startOfLocalDay(new Date(item.dueAt)).getTime() < startOfLocalDay().getTime();
}
const isMobile = ref(false);
function cardStatusText(item: ActivityDto) {
if (item.status === 'done') return 'انجام شد';
if (item.status === 'canceled') return 'لغو شده';
return 'انجام نشده';
}
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 dateText(item: ActivityDto) {
if (!item.dueAt) 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 = 'بارگذاری گروه‌های فعالیت ناموفق بود.';
}
}
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,
},
});
}
const activeBoardUsers = computed(() => users.value.filter((user) => user.isActive !== false));
const boardMembersOpenToAll = computed(() => {
const ids = activeBoardUsers.value.map((user) => user.id);
const selected = boardModal.value?.memberIds ?? [];
return ids.length > 0 && ids.every((id) => selected.includes(id));
});
const filteredBoardUsers = computed(() => {
const query = boardMemberQuery.value.trim().replace(/ي/g, 'ی').replace(/ك/g, 'ک');
const selected = new Set(boardModal.value?.memberIds ?? []);
const list = activeBoardUsers.value.filter((user) => {
if (!query) return true;
return user.fullName.replace(/ي/g, 'ی').replace(/ك/g, 'ک').includes(query);
});
return list.slice().sort((a, b) => Number(selected.has(b.id)) - Number(selected.has(a.id)));
});
function showExistingBoardMembers() {
const modal = boardModal.value;
if (!modal || modal.mode !== 'edit') return;
const saved = currentBoard.value?.memberIds ?? [];
modal.memberIds = saved.length
? [...saved]
: activeBoardUsers.value.map((user) => user.id);
}
async function ensureBoardUsers() {
if (!users.value.length) {
try {
const { data: rows } = await api.get<OrgUserDto[]>('/users');
users.value = rows.filter((user) => user.isActive !== false);
} catch {
users.value = [];
}
}
showExistingBoardMembers();
}
function memberIdsToSave(selected: string[]) {
const all = activeBoardUsers.value.map((user) => user.id);
if (all.length && selected.length === all.length && all.every((id) => selected.includes(id))) {
return [];
}
return selected;
}
function openCreateBoard() {
boardMemberQuery.value = '';
boardModal.value = { mode: 'create', name: '', memberIds: [] };
void ensureBoardUsers();
}
function openEditBoard() {
if (!currentBoard.value) return;
boardMemberQuery.value = '';
boardModal.value = {
mode: 'edit',
name: currentBoard.value.name,
memberIds: [...(currentBoard.value.memberIds ?? [])],
};
void ensureBoardUsers();
}
function toggleBoardMember(userId: string) {
const modal = boardModal.value;
if (!modal) return;
modal.memberIds = modal.memberIds.includes(userId)
? modal.memberIds.filter((id) => id !== userId)
: [...modal.memberIds, userId];
}
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,
memberIds: memberIdsToSave(modal.memberIds),
});
await loadBoards();
boardModal.value = null;
setBoard(created.id);
} else if (currentBoard.value) {
await api.patch(`/activities/boards/${currentBoard.value.id}`, {
name,
memberIds: memberIdsToSave(modal.memberIds),
});
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,
},
});
}
const viewModel = computed<ViewMode>({
get: () => viewMode.value,
set: (v) => setViewMode(v),
});
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);
});
}
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;
}
let mobileQuery: MediaQueryList | null = null;
function syncMobile() {
isMobile.value = mobileQuery?.matches ?? false;
}
onMounted(() => {
mobileQuery = window.matchMedia('(max-width: 899px)');
syncMobile();
mobileQuery.addEventListener('change', syncMobile);
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(() => {
mobileQuery?.removeEventListener('change', syncMobile);
unbindBoardMenuDismiss();
});
</script>
<template>
<section class="page" @click="closePopovers">
<UiBusyOverlay :open="pageBusy" :message="busyMessage" />
<nav class="crumbs" aria-label="مسیر">
<RouterLink :to="{ name: 'home' }">خانه</RouterLink>
<span>‹</span>
<span>میز کار</span>
</nav>
<UiPageHeader
title="میز کار و فعالیت‌ها"
lead="کارهای امروز، ارجاع به همکار و پیگیری وضعیت."
>
<template #actions>
<UiButton variant="ghost" type="button" :disabled="busy" :loading="pageBusy" @click.stop="exportExcel">
2026-09-15 17:33:05 +03:30
<span class="btn-full">خروجی اکسل</span>
<span class="btn-short">خروجی</span>
</UiButton>
2026-09-19 15:30:31 +03:30
<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"
2026-09-19 15:30:31 +03:30
:disabled="busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<template #default="{ loading }">
<UiButton variant="ghost" type="button" :disabled="busy" :loading="loading">
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</UiButton>
</template>
2026-09-19 15:30:31 +03:30
</ExcelImportFlow>
<UiButton variant="primary" @click="activityModal = {}">
<UiIcon name="plus" :size="16" />
فعالیت جدید
</UiButton>
<RouterLink :to="{ name: 'activities-calendar' }">
<UiButton variant="ghost">
<UiIcon name="calendar" :size="16" />
تقویم
</UiButton>
</RouterLink>
</template>
</UiPageHeader>
<div class="view-row">
<div v-if="showDeskTabs" class="desk-tabs" role="tablist" aria-label="محدوده میز کار">
<button
v-for="tab in DESK_TABS"
:key="tab.key"
type="button"
role="tab"
:class="{ on: desk === tab.key }"
:aria-selected="desk === tab.key"
@click="setDesk(tab.key)"
>
{{ tab.label }}
</button>
</div>
<div class="view-row-end">
<UiDropdownMenu
v-if="boards.length"
:open="boardMenuOpen"
align="start"
@update:open="(v) => (boardMenuOpen = v)"
>
<template #trigger="{ toggle }">
<button
type="button"
class="board-trigger"
aria-label="گروه فعالیت"
:title="currentBoard?.name"
@click.stop="toggle"
>
<UiIcon name="layers" :size="14" />
<span>{{ currentBoard?.name ?? 'گروه' }}</span>
<UiIcon name="chevron" :size="13" class="board-chev" />
</button>
</template>
<button
v-for="b in boards"
:key="b.id"
type="button"
:class="{ on: b.id === board }"
@click="setBoard(b.id)"
>
{{ b.name }}
</button>
<template v-if="canManageStages">
<hr class="board-menu-sep" />
<button type="button" class="board-menu-action" @click="openCreateBoard">
<UiIcon name="plus" :size="14" />
گروه جدید
</button>
<button type="button" class="board-menu-action" @click="openEditBoard">
<UiIcon name="pencil" :size="14" />
ویرایش این گروه
</button>
</template>
</UiDropdownMenu>
<UiButton
v-if="canManageStages"
variant="ghost"
size="sm"
type="button"
@click="stagesManagerOpen = true"
>
<UiIcon name="settings" :size="15" />
مدیریت ستون‌ها
</UiButton>
<UiButton
v-if="canManageStages"
variant="ghost"
size="sm"
type="button"
@click="typesManagerOpen = true"
>
<UiIcon name="sliders" :size="15" />
انواع فعالیت و فیلدها
</UiButton>
<UiSegmented v-model="viewModel" :options="VIEW_OPTIONS">
<template #icon="{ option }">
<UiIcon :name="option.icon ?? 'list'" :size="15" />
</template>
</UiSegmented>
</div>
</div>
<div v-if="viewMode === 'tabs'" class="tabs" role="tablist" aria-label="ستون‌های فعالیت">
<button
type="button"
role="tab"
:class="{ on: stage === 'all' }"
:aria-selected="stage === 'all'"
@click="setStage('all')"
>
<span>همه</span>
<em>{{ toFaDigits(stageCounts.total.toLocaleString('en-US')) }}</em>
</button>
<button
v-for="s in stages"
:key="s.id"
type="button"
role="tab"
:class="{ on: stage === s.id }"
:aria-selected="stage === s.id"
@click="setStage(s.id)"
>
<i class="stage-dot" :style="stageDotStyle(s)" aria-hidden="true" />
<span>{{ s.name }}</span>
<em>{{ toFaDigits((stageCounts.counts[s.id] ?? 0).toLocaleString('en-US')) }}</em>
</button>
</div>
<div class="time-chips" role="tablist" aria-label="فیلتر زمانی">
<button
v-for="tab in TIME_TABS"
:key="tab.key"
type="button"
role="tab"
:class="{ on: filter === tab.key, late: tab.key === 'overdue' }"
:aria-selected="filter === tab.key"
@click="setFilter(tab.key)"
>
{{ tab.label }}
<em>{{ toFaDigits(counts[tab.key].toLocaleString('en-US')) }}</em>
</button>
</div>
<div class="tools">
<form class="search" @submit.prevent="onSearch">
<UiIcon name="search" :size="18" />
<input
v-model="q"
type="search"
placeholder="جستجو در عنوان، مشتری، کارشناس، نوع فعالیت..."
/>
</form>
<div class="filter-wrap" @click.stop>
<button
class="btn ghost filter-btn"
type="button"
:class="{ on: activeFacetCount > 0 }"
@click="filterOpen = !filterOpen"
>
<UiIcon name="filter" :size="16" />
{{ filterBtnLabel }}
<UiIcon name="chevron" :size="14" class="chev" />
</button>
<div v-if="filterOpen" class="popover filter-popover">
<p class="facet-title">نوع کار</p>
<button
v-for="opt in TYPE_FILTERS"
:key="`type-${opt.key}`"
type="button"
:class="{ on: typeFilter === opt.key }"
@click="setTypeFilter(opt.key)"
>
{{ opt.label }}
</button>
<p class="facet-title">وضعیت</p>
<button
v-for="opt in STATUS_FILTERS"
:key="`status-${opt.key}`"
type="button"
:class="{ on: statusFilter === opt.key }"
@click="setStatusFilter(opt.key)"
>
{{ opt.label }}
</button>
<template v-if="showDeskTabs">
<p class="facet-title">کارشناس</p>
<button
type="button"
:class="{ on: !assigneeFilter }"
@click="setAssigneeFilter('')"
>
همه کارشناسان
</button>
<button
v-for="user in users"
:key="user.id"
type="button"
:class="{ on: assigneeFilter === user.id }"
@click="setAssigneeFilter(user.id)"
>
{{ user.fullName }}
</button>
</template>
<button
v-if="activeFacetCount"
type="button"
class="clear-facets"
@click="clearFacets"
>
پاک کردن فیلترها
</button>
</div>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
2026-09-15 17:33:05 +03:30
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<div v-if="selectMode" class="select-bar">
<span>{{ toFaDigits(selectedCount) }} فعالیت انتخاب شده</span>
<button
v-if="selectedCount"
type="button"
class="select-del"
@click.stop="askBulkDelete"
>
حذف انتخاب‌شده‌ها
</button>
<button type="button" class="select-cancel" @click.stop="clearSelect">
انصراف از انتخاب
</button>
</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"
/>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else-if="listEmpty" class="empty-card">
<div class="art" aria-hidden="true">
<svg viewBox="0 0 168 148" fill="none">
<rect x="38" y="18" width="92" height="112" rx="16" fill="#E7F6F3" />
<rect x="50" y="32" width="68" height="84" rx="10" fill="#fff" />
<path d="M62 52h28" stroke="#14b8a6" stroke-width="4" stroke-linecap="round" />
<path d="M62 68h36" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" />
<path d="M62 84h22" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" />
<circle cx="118" cy="112" r="20" fill="#14b8a6" />
<path d="M118 104v16M110 112h16" stroke="#fff" stroke-width="3" stroke-linecap="round" />
</svg>
</div>
<h2>{{ trulyEmpty ? 'هنوز فعالیتی ثبت نشده!' : 'فعالیتی در این فهرست نیست' }}</h2>
<p>
{{
trulyEmpty
? 'اولین تماس، جلسه یا وظیفه‌ات رو ثبت کن تا همه‌چیز رو منظم دنبال کنی.'
: 'فیلتر یا جستجو را عوض کنید، یا یک فعالیت جدید بسازید.'
}}
</p>
<UiButton block type="button" @click="activityModal = {}">
{{ trulyEmpty ? 'اولین فعالیتت رو ثبت کن' : 'فعالیت جدید' }}
</UiButton>
<button class="btn ghost wide" type="button" @click="ideasOpen = !ideasOpen">
<UiIcon name="lightbulb" :size="18" />
ایده‌هایی برای شروع
</button>
<div v-if="ideasOpen" class="idea-list">
<button type="button" @click="activityModal = { type: 'call', title: 'تماس پیگیری' }">
تماس پیگیری
</button>
<button type="button" @click="activityModal = { type: 'meeting', title: 'جلسه دمو' }">
جلسه دمو
</button>
<button type="button" @click="activityModal = { type: 'task', title: 'وظیفه یادآوری' }">
وظیفه یادآوری
</button>
</div>
</div>
<template v-else>
<div class="list-head">
<strong>{{ filterLabel }} — {{ toFaDigits(total.toLocaleString('en-US')) }} فعالیت</strong>
<div class="list-head-tools">
<TableColumnsMenu class="desktop-only" :table-key="ACT_TABLE_KEY" align="end" />
<RouterLink class="cal-mini" :to="{ name: 'activities-calendar' }" aria-label="تقویم">
<UiIcon name="calendar" :size="16" />
</RouterLink>
</div>
</div>
<div class="table desktop-only" role="table">
<div class="thead" role="row" :style="actGridStyle">
<span class="row-num">#</span>
<span v-if="selectMode" class="tick-col" />
<template v-for="col in ACT_COLUMNS" :key="col.key">
<span v-if="colOn(col.key)">{{ col.label }}</span>
</template>
<span />
</div>
<article
v-for="(item, idx) in items"
:key="item.id"
class="trow"
:class="{ selected: selectedIds.has(item.id), 'select-mode': selectMode }"
role="row"
:style="actGridStyle"
:data-card-id="item.id"
@click="selectMode && toggleSelect(item.id)"
>
<span class="row-num">{{ toFaDigits((page - 1) * pageSize + idx + 1) }}</span>
<div v-if="selectMode" class="tick-col" @click.stop>
<button
type="button"
class="tick"
:class="{ on: selectedIds.has(item.id) }"
:aria-label="selectedIds.has(item.id) ? 'لغو انتخاب' : 'انتخاب فعالیت'"
:aria-pressed="selectedIds.has(item.id)"
@click="toggleSelect(item.id)"
>
<UiIcon v-if="selectedIds.has(item.id)" name="check" :size="14" />
</button>
</div>
<div v-if="colOn('title')" class="title-cell">
<UiIcon name="star" :size="16" class="star" />
<div>
<RouterLink class="title" :to="{ name: 'activity-detail', params: { id: item.id } }">
{{ item.title }}
</RouterLink>
<p v-if="subtitle(item)" class="sub">{{ subtitle(item) }}</p>
<p v-if="item.tags?.length" class="row-tags">
<span v-for="t in item.tags.slice(0, 3)" :key="t">#{{ t }}</span>
<span v-if="item.tags.length > 3">+{{ toFaDigits(item.tags.length - 3) }}</span>
</p>
</div>
</div>
<div v-if="colOn('who')" class="who">
<strong>{{
item.assignees?.length
? item.assignees.map((a) => a.fullName).join('، ')
: item.assignee?.fullName || '—'
}}</strong>
</div>
<div v-if="colOn('customer')" class="customer">
<RouterLink
v-if="item.company"
:to="{ name: 'company-detail', params: { id: item.company.id } }"
>
<UiIcon name="building" :size="13" />
{{ item.company.name }}
</RouterLink>
<RouterLink
v-if="item.person"
:to="{ name: 'person-detail', params: { id: item.person.id } }"
:class="{ secondary: Boolean(item.company) }"
>
<UiIcon name="user" :size="13" />
{{ item.person.fullName }}
</RouterLink>
<span v-if="!item.company && !item.person" class="none">—</span>
</div>
<div v-if="colOn('type')" class="type-cell">
<span class="type-pill" :style="typeColorStyle(activityTypeView(item).color)">
<UiIcon :name="activityTypeView(item).icon" :size="13" />
{{ activityTypeView(item).name }}
</span>
</div>
<div v-if="colOn('when')" class="when" :class="{ late: uiStatus(item) === 'overdue' }">
<strong>{{ clockText(item) }}</strong>
<span>{{ dayText(item) }}</span>
</div>
<div v-if="colOn('status')" class="status-cell">
<span class="status" :class="uiStatus(item)">
<UiIcon :name="statusIcon[uiStatus(item)]" :size="13" />
{{ statusLabel[uiStatus(item)] }}
</span>
<small v-if="item.stageId && stageById.get(item.stageId)" class="stage-name">
<i class="stage-dot" :style="stageDotStyle(stageById.get(item.stageId)!)" aria-hidden="true" />
{{ stageById.get(item.stageId)!.name }}
</small>
</div>
<div v-if="!isMobile" class="row-more" @click.stop>
<button
v-if="selectMode && selectedIds.has(item.id)"
type="button"
class="icon-more trash menu"
aria-label="حذف انتخاب‌شده‌ها"
@click="askBulkDelete"
>
<UiIcon name="trash" :size="16" />
</button>
<UiDropdownMenu
v-else
:open="menuId === item.id"
align="end"
@update:open="(v) => (menuId = v ? item.id : null)"
>
<template #trigger="{ toggle }">
<button
class="icon-more menu"
type="button"
aria-label="عملیات"
@click.stop="toggle"
>
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<button type="button" @click="startSelect(item.id)">انتخاب</button>
<RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }">
مشاهده
</RouterLink>
<RouterLink :to="{ name: 'activity-edit', params: { id: item.id } }">
ویرایش
</RouterLink>
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
انتقال به کار جاری
</button>
<button v-if="item.status === 'open'" type="button" @click="complete(item.id)">
انجام شد
</button>
<button type="button" class="danger" @click="askDeleteActivity(item.id, item.title)">
حذف
</button>
</UiDropdownMenu>
</div>
</article>
</div>
<div class="cards mobile-only">
<article
v-for="item in items"
:key="item.id"
class="card"
:class="{ selected: selectedIds.has(item.id), 'select-mode': selectMode }"
:data-card-id="item.id"
@click="selectMode && toggleSelect(item.id)"
>
<div class="card-top">
<button
v-if="selectMode"
type="button"
class="tick"
:class="{ on: selectedIds.has(item.id) }"
:aria-label="selectedIds.has(item.id) ? 'لغو انتخاب' : 'انتخاب فعالیت'"
:aria-pressed="selectedIds.has(item.id)"
@click.stop="toggleSelect(item.id)"
>
<UiIcon v-if="selectedIds.has(item.id)" name="check" :size="14" />
</button>
<div class="card-main">
<time :class="item.type">{{ clockText(item) }}</time>
<RouterLink
class="title"
:to="{ name: 'activity-detail', params: { id: item.id } }"
@click="selectMode && $event.preventDefault()"
>
{{ item.title }}
<UiIcon name="star" :size="14" class="star" />
</RouterLink>
<p v-if="subtitle(item)" class="sub">{{ subtitle(item) }}</p>
<p class="meta-line">
<span v-if="item.person">
<UiIcon name="user" :size="13" />
{{ item.person.fullName }}
</span>
<span v-if="item.company">
<UiIcon name="building" :size="13" />
{{ item.company.name }}
</span>
</p>
</div>
<div class="card-side">
<button
v-if="selectMode && selectedIds.has(item.id)"
type="button"
class="icon-more trash menu"
aria-label="حذف انتخاب‌شده‌ها"
@click.stop="askBulkDelete"
>
<UiIcon name="trash" :size="16" />
</button>
<UiDropdownMenu
v-else-if="isMobile"
:open="menuId === item.id"
align="end"
@update:open="(v) => (menuId = v ? item.id : null)"
>
<template #trigger="{ toggle }">
<button
class="icon-more menu"
type="button"
aria-label="عملیات"
@click.stop="toggle"
>
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<button type="button" @click="startSelect(item.id)">انتخاب</button>
<RouterLink :to="{ name: 'activity-detail', params: { id: item.id } }">
مشاهده
</RouterLink>
<RouterLink :to="{ name: 'activity-edit', params: { id: item.id } }">
ویرایش
</RouterLink>
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
انتقال به کار جاری
</button>
<button v-if="item.status === 'open'" type="button" @click="complete(item.id)">
انجام شد
</button>
<button type="button" class="danger" @click="askDeleteActivity(item.id, item.title)">
حذف
</button>
</UiDropdownMenu>
<span class="type-ico" :style="typeColorStyle(activityTypeView(item).color)">
<UiIcon :name="activityTypeView(item).icon" :size="18" />
</span>
<span class="type-pill" :style="typeColorStyle(activityTypeView(item).color)">
{{ activityTypeView(item).name }}
</span>
</div>
</div>
<footer class="card-foot">
<span>
<UiIcon name="calendar" :size="14" />
{{ dateText(item) }}
</span>
<span class="status" :class="item.status === 'done' ? 'done' : item.status === 'canceled' ? 'canceled' : 'planned'">
{{ cardStatusText(item) }}
</span>
</footer>
</article>
</div>
<UiPagination
v-if="total"
:page="page"
:total-pages="pageCount"
:total-items="total"
:page-size="pageSize"
:page-size-options="PAGE_SIZE_OPTIONS"
@update:page="setPage"
@update:page-size="onPageSize"
/>
</template>
<Teleport to="body">
<div
v-if="boardMenuId"
class="menu-pop"
role="menu"
:style="boardMenuStyle ?? undefined"
@click.stop
>
<button type="button" role="menuitem" @click="startSelect(boardMenuId)">انتخاب</button>
<RouterLink
role="menuitem"
:to="{ name: 'activity-detail', params: { id: boardMenuId } }"
@click="boardMenuId = null; boardMenuActivity = null"
>
مشاهده
</RouterLink>
<RouterLink
role="menuitem"
:to="{ name: 'activity-edit', params: { id: boardMenuId } }"
@click="boardMenuId = null; boardMenuActivity = null"
>
ویرایش
</RouterLink>
<button
v-if="boardMenuActivity && canPromotePrior(boardMenuActivity)"
type="button"
role="menuitem"
@click="promotePriorActivity(boardMenuActivity.id)"
>
انتقال به کار جاری
</button>
<button
type="button"
role="menuitem"
class="danger"
@click="askDeleteActivity(boardMenuId)"
>
حذف
</button>
<button type="button" role="menuitem" @click="boardMenuId = null; boardMenuActivity = null">بستن</button>
</div>
</Teleport>
<UiModal
:open="Boolean(activityModal)"
title="فعالیت جدید"
@close="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"
/>
</UiModal>
<UiModal
:open="deleteActivityIds.length > 0"
:title="deleteActivityIds.length > 1 ? 'حذف فعالیت‌ها' : 'حذف فعالیت'"
@close="deleteActivityIds = []"
>
<p class="delete-copy">{{ deleteConfirmCopy }}</p>
<template #footer>
<UiButton type="button" :disabled="activityDeleting" @click="confirmDeleteActivities">
{{ activityDeleting ? 'در حال حذف…' : 'حذف' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="deleteActivityIds = []">انصراف</UiButton>
</template>
</UiModal>
<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"
/>
<UiModal
:open="Boolean(boardModal)"
:title="boardModal?.mode === 'create' ? 'گروه فعالیت جدید' : 'ویرایش گروه فعالیت'"
@close="boardModal = null"
>
<form v-if="boardModal" class="board-form" @submit.prevent="saveBoard">
<label class="board-form-label" for="activity-board-name">نام گروه</label>
<UiInput
id="activity-board-name"
v-model="boardModal.name"
:maxlength="80"
placeholder="مثلاً پشتیبانی، نصب، پیگیری فروش…"
/>
<p v-if="boardModal.mode === 'create'" class="board-form-hint">
گروه جدید با ستون‌های پیش‌فرض ساخته می‌شود و بعد می‌توانید ستون‌هایش را تغییر دهید.
</p>
<label class="board-form-label" for="activity-board-member-search">اعضا</label>
<UiInput
id="activity-board-member-search"
v-model="boardMemberQuery"
placeholder="جستجوی نام عضو…"
/>
<p class="board-form-hint">
{{
boardMembersOpenToAll
? `اعضای فعلی همین ${toFaDigits(boardModal.memberIds.length)} نفر هستند و گروه برای همهٔ محیط باز است. با برداشتن تیک، فقط افراد انتخاب‌شده آن را می‌بینند.`
: boardModal.memberIds.length
? `${toFaDigits(boardModal.memberIds.length)} نفر انتخاب شده. فقط همین افراد این گروه را می‌بینند.`
: 'اگر کسی را انتخاب نکنید، این گروه برای همهٔ اعضای محیط دیده می‌شود.'
}}
</p>
<ul class="board-members">
<li v-if="!filteredBoardUsers.length" class="board-form-hint">عضوی پیدا نشد.</li>
<li v-for="user in filteredBoardUsers" :key="user.id">
<label>
<input
type="checkbox"
:checked="boardModal.memberIds.includes(user.id)"
@change="toggleBoardMember(user.id)"
/>
<span>{{ user.fullName }}</span>
</label>
</li>
</ul>
<UiButton
v-if="boardModal.mode === 'edit' && currentBoard && !currentBoard.isDefault"
variant="ghost"
size="sm"
type="button"
class="board-delete"
@click="boardDeleteOpen = true"
>
<UiIcon name="trash" :size="14" />
حذف این گروه
</UiButton>
</form>
<template #footer>
<UiButton
type="button"
:disabled="boardSaving || (boardModal?.name.trim().length ?? 0) < 2"
@click="saveBoard"
>
{{ boardSaving ? 'در حال ذخیره…' : boardModal?.mode === 'create' ? 'ساخت گروه' : 'ذخیره' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="boardModal = null">انصراف</UiButton>
</template>
</UiModal>
<UiModal
:open="boardDeleteOpen"
title="حذف گروه فعالیت"
@close="boardDeleteOpen = false"
>
<p class="delete-copy">
گروه «{{ currentBoard?.name }}» و ستون‌هایش حذف شود؟ فعالیت‌های این گروه حذف نمی‌شوند و به
گروه اصلی منتقل می‌شوند.
</p>
<template #footer>
<UiButton type="button" :disabled="boardSaving" @click="deleteBoard">
{{ boardSaving ? 'در حال حذف…' : 'حذف گروه' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="boardDeleteOpen = false">انصراف</UiButton>
</template>
</UiModal>
<ActivityTypesManager
v-if="canManageStages"
:open="typesManagerOpen"
@close="typesManagerOpen = false"
@changed="onTypesChanged"
/>
</section>
</template>
<style scoped>
.page {
2026-10-19 11:18:19 +03:30
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
2026-10-19 11:18:19 +03:30
min-height: 0;
height: 100%;
}
.crumbs {
display: flex;
gap: 0.35rem;
align-items: center;
color: #94a3b8;
font-size: 0.78rem;
}
.crumbs a {
color: inherit;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.eyebrow {
margin: 0;
color: #0f766e;
font-weight: 700;
font-size: 0.8rem;
}
h1 {
margin: 0.15rem 0 0.3rem;
font-size: 1.85rem;
}
.lead,
.muted,
.sub,
.error {
margin: 0;
}
.lead,
.muted,
.sub {
color: #64748b;
}
.error {
color: #b91c1c;
}
2026-09-15 17:33:05 +03:30
.ok {
color: #0f766e;
font-weight: 600;
}
.btn-short {
2026-09-15 17:33:05 +03:30
display: none;
}
.head-actions,
.tools {
display: flex;
gap: 0.55rem;
align-items: center;
2026-09-15 17:33:05 +03:30
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
height: var(--control-h);
min-height: var(--control-h);
padding: 0 0.85rem;
border-radius: var(--radius);
font-weight: 600;
font-size: var(--fs-sm);
border: 1px solid transparent;
cursor: pointer;
font: inherit;
text-decoration: none;
box-sizing: border-box;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.btn.primary {
background: var(--accent);
color: #fff;
}
.btn.ghost {
background: #fff;
color: var(--ink-secondary);
border-color: var(--line);
}
.btn.ghost:hover {
background: var(--surface-subtle);
border-color: var(--line-strong);
}
.btn.wide {
width: min(100%, 320px);
}
.desk-tabs {
display: inline-flex;
gap: 0.25rem;
padding: 0.2rem;
border-radius: 12px;
background: #f1f5f9;
width: fit-content;
}
.desk-tabs button {
border: 0;
background: transparent;
color: #64748b;
font: inherit;
font-size: 0.86rem;
font-weight: 700;
padding: 0.45rem 0.85rem;
border-radius: 10px;
cursor: pointer;
}
.desk-tabs button.on {
background: #fff;
color: #0f766e;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
.view-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
flex-wrap: wrap;
}
.view-row-end {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-inline-start: auto;
}
.board-trigger {
display: inline-flex;
align-items: center;
gap: 0.4rem;
height: 30px;
max-width: 14rem;
padding: 0 0.6rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface, #fff);
color: var(--ink, #0f172a);
font: inherit;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
}
.board-trigger:hover {
border-color: var(--accent, #0d9488);
}
.board-trigger span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.board-chev {
color: var(--muted);
transform: rotate(-90deg);
}
.board-menu-sep {
margin: 0.25rem 0;
border: 0;
border-top: 1px solid var(--line, #e2e8f0);
}
.board-menu-action {
display: flex !important;
align-items: center;
gap: 0.4rem;
color: var(--accent-deep, #0f766e) !important;
}
.board-form {
display: grid;
gap: 0.5rem;
}
.board-form-label {
font-size: 0.85rem;
font-weight: 700;
color: #334155;
}
.board-form-hint {
margin: 0;
font-size: 0.8rem;
color: var(--muted);
line-height: 1.6;
}
.board-members {
list-style: none;
margin: 0;
padding: 0.25rem;
max-height: 14rem;
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
display: grid;
gap: 0.1rem;
}
.board-members label {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.45rem;
border-radius: var(--radius);
cursor: pointer;
}
.board-members label:hover {
background: #f8fafc;
}
.board-delete {
justify-self: start;
color: var(--danger);
}
.stage-dot {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
flex: 0 0 auto;
}
.time-chips {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.time-chips button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--line);
background: #fff;
color: #475569;
font: inherit;
font-size: 0.82rem;
font-weight: 700;
padding: 0.32rem 0.75rem;
border-radius: 999px;
cursor: pointer;
}
.time-chips button em {
font-style: normal;
font-size: 0.7rem;
color: #64748b;
}
.time-chips button.late em {
color: #dc2626;
}
.time-chips button.on {
border-color: #5eead4;
background: #f0fdfa;
color: #0f766e;
}
.status-cell {
display: grid;
gap: 0.25rem;
justify-items: start;
min-width: 0;
}
.stage-name {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: #64748b;
font-size: 0.72rem;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tabs {
display: flex;
gap: 0.2rem;
overflow-x: auto;
border-bottom: 1px solid #e2e8f0;
}
.tabs button {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 0;
background: none;
padding: 0.7rem 0.85rem;
color: #64748b;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
box-shadow: inset 0 -2px 0 transparent;
}
.tabs button em {
font-style: normal;
font-size: 0.72rem;
background: #f1f5f9;
border-radius: 999px;
padding: 0.05rem 0.4rem;
}
.tabs button.on {
color: #0f766e;
box-shadow: inset 0 -2px 0 #14b8a6;
}
.tabs button.on em {
background: #ccfbf1;
}
.search {
flex: 1 1 200px;
min-width: 180px;
max-width: 400px;
height: var(--control-h);
min-height: var(--control-h);
display: flex;
align-items: center;
gap: 0.5rem;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 0.75rem;
color: var(--muted);
box-sizing: border-box;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.search:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: none;
padding: 0;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink);
}
.filter-wrap,
.row-more,
.card-side {
position: relative;
}
.filter-btn .chev {
transform: rotate(90deg);
}
.filter-btn.on {
border-color: #99f6e4;
background: #f0fdfa;
color: #0f766e;
}
.popover {
position: absolute;
top: calc(100% + 6px);
inset-inline-end: 0;
min-width: 11rem;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
padding: 0.35rem;
display: grid;
z-index: 20;
}
.filter-popover {
min-width: 14rem;
max-height: min(70vh, 28rem);
overflow: auto;
}
.facet-title {
margin: 0.45rem 0.35rem 0.15rem;
font-size: 0.72rem;
font-weight: 700;
color: #64748b;
}
.facet-title:first-child {
margin-top: 0.15rem;
}
.clear-facets {
margin-top: 0.25rem;
border-top: 1px solid #e2e8f0 !important;
border-radius: 0 0 var(--radius) var(--radius) !important;
color: #b91c1c !important;
}
.popover a,
.popover button {
display: block;
width: 100%;
text-align: start;
border: 0;
background: none;
padding: 0.6rem 0.7rem;
border-radius: var(--radius);
font: inherit;
cursor: pointer;
color: #0f172a;
}
.popover a:hover,
.popover button:hover,
.popover button.on {
background: #f0fdfa;
color: #0f766e;
}
.empty-card {
background: #fff;
border-radius: var(--radius);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
padding: 2.4rem 1.2rem 2rem;
display: grid;
justify-items: center;
text-align: center;
gap: 0.7rem;
}
.art svg {
width: 148px;
height: auto;
display: block;
}
.empty-card h2 {
margin: 0.2rem 0 0;
font-size: 1.15rem;
}
.empty-card > p {
max-width: 22rem;
color: #94a3b8;
line-height: 1.8;
}
.idea-list {
display: grid;
gap: 0.4rem;
width: min(100%, 320px);
}
.idea-list a,
.idea-list button {
border: 0;
width: 100%;
text-align: right;
font: inherit;
cursor: pointer;
background: #f8fafc;
border-radius: var(--radius);
padding: 0.65rem 0.8rem;
color: #0f766e;
font-weight: 700;
}
.list-head {
display: flex;
justify-content: space-between;
align-items: center;
color: #0f766e;
}
.cal-mini {
color: #0f766e;
}
.list-head-tools {
display: flex;
align-items: center;
gap: 0.6rem;
}
.table {
background: #fff;
border-radius: var(--radius);
overflow: visible;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.thead,
.trow {
display: grid;
grid-template-columns: 2.4rem minmax(0, 2fr) minmax(7rem, 0.9fr) minmax(7rem, 1fr) 7.6rem 7.2rem 8.6rem 2.4rem;
gap: 0.8rem;
align-items: center;
padding: 0.95rem 1.1rem;
}
.thead {
color: #94a3b8;
font-size: 0.78rem;
font-weight: 600;
border-bottom: 1px solid #f1f5f9;
border-radius: var(--radius) var(--radius) 0 0;
}
.trow {
position: relative;
border-bottom: 1px solid #f8fafc;
}
.row-num {
color: #94a3b8;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
text-align: center;
}
.trow:last-child {
border-bottom: 0;
border-radius: 0 0 var(--radius) var(--radius);
}
.title-cell {
display: flex;
gap: 0.55rem;
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.title-cell > div {
min-width: 0;
flex: 1;
overflow: hidden;
}
.star {
color: #cbd5e1;
margin-top: 0.2rem;
flex: 0 0 auto;
}
.title {
display: block;
color: inherit;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
margin: 0.2rem 0 0;
font-size: 0.8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.type-cell {
min-width: 0;
}
.type-pill {
display: inline-flex;
align-items: center;
gap: 0.28rem;
border-radius: 999px;
padding: 0.12rem 0.5rem;
font-size: 0.72rem;
font-weight: 700;
}
.type-pill.call,
.type-ico.call,
time.call {
background: #ecfdf5;
color: #0f766e;
}
.type-pill.meeting,
.type-ico.meeting,
time.meeting {
background: #f5f3ff;
color: #6d28d9;
}
.type-pill.task,
.type-ico.task,
time.task {
background: #fff7ed;
color: #c2410c;
}
.type-pill.note,
.type-ico.note,
time.note {
background: #eff6ff;
color: #2563eb;
}
.when,
.who {
display: grid;
gap: 0.15rem;
min-width: 0;
}
.when strong,
.who strong {
font-size: 0.9rem;
}
.when span,
.who span {
color: #94a3b8;
font-size: 0.75rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
.row-tags {
margin: 0.2rem 0 0;
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.row-tags span {
background: #f0fdfa;
color: #0f766e;
border-radius: 999px;
padding: 0.05rem 0.45rem;
font-size: 0.7rem;
font-weight: 600;
}
.customer {
display: grid;
gap: 0.15rem;
min-width: 0;
}
.customer a {
display: inline-flex;
align-items: center;
gap: 0.3rem;
min-width: 0;
color: var(--ink);
font-size: 0.86rem;
font-weight: 600;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.customer a:hover {
color: #0f766e;
}
.customer a.secondary {
color: #94a3b8;
font-size: 0.75rem;
font-weight: 500;
}
.customer .none {
color: #cbd5e1;
}
.when.late {
color: #dc2626;
}
.when.late span {
color: #ef4444;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.28rem;
border-radius: 999px;
padding: 0.28rem 0.55rem;
font-size: 0.74rem;
font-weight: 700;
white-space: nowrap;
}
.status.done {
background: #dcfce7;
color: #166534;
}
.status.progress {
background: #dbeafe;
color: #1d4ed8;
}
.status.planned {
background: #ffedd5;
color: #c2410c;
}
.status.overdue,
.status.canceled {
background: #fee2e2;
color: #b91c1c;
}
.icon-more {
width: 32px;
height: 32px;
border: 0;
background: none;
color: #64748b;
border-radius: var(--radius);
display: grid;
place-items: center;
cursor: pointer;
}
.icon-more.trash {
color: var(--danger, #b91c1c);
}
.tick-col {
display: grid;
place-items: center;
}
.tick {
width: 1.7rem;
height: 1.7rem;
border: 1.5px solid #cbd5e1;
border-radius: 999px;
background: #fff;
color: #fff;
display: grid;
place-items: center;
padding: 0;
cursor: pointer;
}
.tick.on {
border-color: var(--accent, #14b8a6);
background: var(--accent, #14b8a6);
}
.trow.selected,
.card.selected {
background: color-mix(in srgb, var(--accent, #14b8a6) 6%, #fff);
}
.trow.select-mode {
cursor: pointer;
}
.select-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.65rem 1rem;
padding: 0.55rem 0.85rem;
border-radius: var(--radius);
background: color-mix(in srgb, var(--accent, #14b8a6) 10%, #fff);
border: 1px solid color-mix(in srgb, var(--accent, #14b8a6) 25%, #e2e8f0);
font-size: 0.86rem;
font-weight: 700;
color: var(--ink, #0f172a);
}
.select-bar span {
flex: 1;
min-width: 8rem;
}
.select-bar button {
border: 0;
background: none;
padding: 0;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
}
.select-bar .select-del {
color: var(--danger, #b91c1c);
}
.select-bar .select-cancel {
color: var(--accent, #0f766e);
}
.menu-pop {
position: fixed;
z-index: 1200;
background: #fff;
border: 1px solid var(--line, #e2e8f0);
border-radius: var(--radius);
box-shadow: var(--shadow, 0 16px 40px rgba(15, 23, 42, 0.14));
padding: 0.35rem;
display: grid;
gap: 0.15rem;
min-width: 10rem;
}
.menu-pop button,
.menu-pop a {
border: 0;
background: transparent;
padding: 0.45rem 0.85rem;
cursor: pointer;
font: inherit;
color: var(--ink, #0f172a);
text-align: right;
text-decoration: none;
border-radius: calc(var(--radius) - 4px);
}
.menu-pop button:hover,
.menu-pop a:hover {
background: #f1f5f9;
}
.menu-pop button.danger {
color: var(--danger, #b91c1c);
}
.delete-copy {
margin: 0;
line-height: 1.7;
}
.cards {
display: grid;
gap: 0.75rem;
}
.card {
background: #fff;
border: 1px solid #eef2f6;
border-radius: var(--radius);
padding: 0.9rem 0.95rem 0.75rem;
}
.card-top {
display: flex;
gap: 0.7rem;
justify-content: space-between;
}
.card-main {
min-width: 0;
flex: 1;
}
.card-main time {
display: inline-block;
background: none;
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 0.2rem;
}
.card-main .title {
display: flex;
align-items: flex-start;
gap: 0.3rem;
}
.meta-line {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
margin: 0.45rem 0 0;
color: #64748b;
font-size: 0.78rem;
}
.meta-line span {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.card-side {
display: grid;
justify-items: center;
gap: 0.35rem;
flex-shrink: 0;
}
.type-ico {
width: 42px;
height: 42px;
border-radius: 999px;
display: grid;
place-items: center;
}
.card-foot {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.75rem;
padding-top: 0.7rem;
border-top: 1px solid #f1f5f9;
color: #64748b;
font-size: 0.78rem;
}
.card-foot span {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.done-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 0;
background: #ecfdf5;
color: #0f766e;
border-radius: 999px;
padding: 0.38rem 0.7rem;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.pager {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
color: #64748b;
font-size: 0.8rem;
}
.pages {
display: flex;
gap: 0.3rem;
}
.pages button {
min-width: 32px;
height: 32px;
border: 0;
background: #fff;
border-radius: var(--radius);
cursor: pointer;
}
.pages button.on {
background: #14b8a6;
color: #fff;
}
.pages button:disabled {
opacity: 0.4;
}
.mobile-only {
display: none;
}
@media (max-width: 899px) {
.crumbs,
.desktop-only,
.tabs button em,
.pager .per {
display: none;
}
.mobile-only {
display: grid;
}
.page-head {
flex-direction: column;
}
.head-actions {
width: 100%;
}
.head-actions .btn {
flex: 1;
}
.btn-full {
2026-09-15 17:33:05 +03:30
display: none;
}
.btn-short {
2026-09-15 17:33:05 +03:30
display: inline;
}
h1 {
font-size: 1.55rem;
}
.tabs {
border-bottom: 0;
gap: 0.4rem;
}
.tabs button {
background: #fff;
border-radius: 999px;
box-shadow: none;
padding: 0.48rem 0.8rem;
}
.tabs button.on {
background: #ccfbf1;
box-shadow: none;
}
.tools {
align-items: stretch;
}
.search {
flex: 1 1 auto;
}
.list-head {
padding-top: 0.2rem;
}
}
@media (max-width: 599px) {
.thead,
.trow {
grid-template-columns: 1fr;
}
.row-num {
display: none;
}
}
</style>