Files
CRM_Montzar/apps/web/src/views/ActivitiesView.vue
T
MahdiandCursor 33eeb3242a Add activity priority desk filter and keep detail browsing inside the same column after stage changes.
When done/reopen/stage moves leave a column, advance to the next activity there or return to the list if empty.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 12:02:13 +03:30

3653 lines
95 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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,
UiPagination,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import ActivitiesKanban from '@/components/activities/ActivitiesKanban.vue';
import ActivityDeskFilters from '@/components/activities/ActivityDeskFilters.vue';
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
import { useActivityPriorities } from '@/lib/activity-priorities';
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,
ActivityPriority,
ActivityStageCountsDto,
ActivityStageDto,
ActivityStatus,
ActivityType,
ImportContactsResult,
OrgUserDto,
PaginatedResponse,
} from '@mento/shared';
import { ACTIVITY_PRIORITY_KEYS, brand } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canMutateActivity } from '@/lib/activity-access';
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
import { apiError } from '@/lib/routines';
import { message } from 'ant-design-vue';
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: '9.2rem' },
{ key: 'when', label: 'زمان شروع', width: '8.6rem' },
{ key: 'status', label: 'وضعیت', width: '9.2rem' },
] 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 PriorityFilter = 'all' | ActivityPriority;
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_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 activityPriorities = useActivityPriorities();
const PRIORITY_FILTERS = computed<
Array<{ key: PriorityFilter; label: string; color?: string }>
>(() => [
{ key: 'all', label: 'همه اولویت‌ها' },
...activityPriorities.items.value.map((row) => ({
key: row.key as PriorityFilter,
label: row.label,
color: row.color,
})),
]);
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 actionsMenuOpen = ref(false);
const importBtn = ref<HTMLButtonElement | null>(null);
function triggerImport() {
importBtn.value?.click();
}
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);
await activityPriorities.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 notice = 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 openCol = ref<ActColKey | null>(null);
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 priorityFilter = computed<PriorityFilter>(() => {
const value = typeof route.query.priority === 'string' ? route.query.priority : 'all';
return ACTIVITY_PRIORITY_KEYS.includes(value as ActivityPriority)
? (value as ActivityPriority)
: 'all';
});
const assigneeFilter = computed(() =>
typeof route.query.assigneeId === 'string' ? route.query.assigneeId : '',
);
const titleFilter = computed(() =>
typeof route.query.title === 'string' ? route.query.title : '',
);
const customerFilter = computed(() =>
typeof route.query.customer === 'string' ? route.query.customer : '',
);
const dueFilter = computed<'' | 'set' | 'unset'>(() => {
const value = route.query.due;
return value === 'set' || value === 'unset' ? value : '';
});
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 boardMenuStageId = ref('');
const boardMenuStyle = ref<Record<string, string> | null>(null);
const deleteActivityIds = ref<string[]>([]);
const deleteSingleTitle = ref('');
const activityDeleting = ref(false);
const busy = ref(false);
const pageBusy = ref(false);
const busyMessage = ref('لطفاً صبر کنید…');
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,
priority: priorityFilter.value === 'all' ? undefined : priorityFilter.value,
assigneeId: assigneeFilter.value || undefined,
title: titleFilter.value.trim() || undefined,
customer: customerFilter.value.trim() || undefined,
due: dueFilter.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 (priorityFilter.value !== 'all') n += 1;
if (statusFilter.value !== 'all') n += 1;
if (assigneeFilter.value) n += 1;
if (titleFilter.value.trim()) n += 1;
if (customerFilter.value.trim()) n += 1;
if (dueFilter.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 = 'بارگذاری ستون‌های فعالیت ناموفق بود.';
}
}
/** زمینهٔ همان فهرست میز، برای فعالیت قبلی/بعدی. هر بار باز کردن یک nav تازه می‌سازد. */
function deskDetailQuery(columnStageId?: string): Record<string, string> {
const query: Record<string, string> = {
from: 'desk',
nav: crypto.randomUUID(),
view: viewMode.value,
filter: filter.value,
};
if (board.value) query.board = board.value;
if (showDeskTabs.value) query.desk = desk.value;
const search = typeof route.query.q === 'string' ? route.query.q.trim() : '';
if (search) query.q = search;
if (typeFilter.value !== 'all') query.typeDefId = typeFilter.value;
if (statusFilter.value !== 'all') query.status = statusFilter.value;
if (priorityFilter.value !== 'all') query.priority = priorityFilter.value;
if (assigneeFilter.value) query.assigneeId = assigneeFilter.value;
if (titleFilter.value.trim()) query.title = titleFilter.value.trim();
if (customerFilter.value.trim()) query.customer = customerFilter.value.trim();
if (dueFilter.value) query.due = dueFilter.value;
const stageId =
viewMode.value === 'board'
? columnStageId
: stage.value !== 'all'
? stage.value
: undefined;
if (stageId) query.stage = stageId;
return query;
}
function activityRoute(id: string, columnStageId?: string) {
return {
name: 'activity-detail' as const,
params: { id },
query: deskDetailQuery(columnStageId),
};
}
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,
},
});
}
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: string) {
const value: StatusFilter =
next === 'open' || next === 'done' || next === 'canceled' ? next : 'all';
patchFacetQuery({ status: value === 'all' ? undefined : value });
}
function setPriorityFilter(next: string) {
const value: PriorityFilter = ACTIVITY_PRIORITY_KEYS.includes(next as ActivityPriority)
? (next as ActivityPriority)
: 'all';
patchFacetQuery({ priority: value === 'all' ? undefined : value });
}
function setAssigneeFilter(next: string) {
patchFacetQuery({
assigneeId: next || undefined,
desk: next && showDeskTabs.value ? 'team' : route.query.desk === 'team' ? 'team' : undefined,
});
}
function setTitleFilter(next: string) {
patchFacetQuery({ title: next.trim() || undefined });
}
function setCustomerFilter(next: string) {
patchFacetQuery({ customer: next.trim() || undefined });
}
function setDueFilter(next: '' | 'set' | 'unset') {
patchFacetQuery({ due: next || undefined });
}
function clearFacets() {
patchFacetQuery({
type: undefined,
typeDefId: undefined,
status: undefined,
priority: undefined,
assigneeId: undefined,
title: undefined,
customer: undefined,
due: undefined,
});
}
function colFiltered(key: ActColKey) {
if (key === 'title') return Boolean(titleFilter.value.trim());
if (key === 'who') return Boolean(assigneeFilter.value);
if (key === 'customer') return Boolean(customerFilter.value.trim());
if (key === 'type') return typeFilter.value !== 'all';
if (key === 'when') return Boolean(dueFilter.value);
if (key === 'status') return statusFilter.value !== 'all';
return false;
}
function toggleFilter() {
openCol.value = null;
filterOpen.value = !filterOpen.value;
}
function toggleCol(key: ActColKey) {
if (key === 'who' && !showDeskTabs.value) return;
filterOpen.value = false;
openCol.value = openCol.value === key ? null : key;
}
function onFacetDone() {
openCol.value = null;
}
function assigneePeopleOf(item: ActivityDto) {
if (item.assignees?.length) return item.assignees;
return item.assignee ? [item.assignee] : [];
}
function needsEveryone(item: ActivityDto) {
return item.completionMode === 'all' && assigneePeopleOf(item).length > 1;
}
function canMarkShare(item: ActivityDto) {
if (item.status !== 'open') return false;
if (!needsEveryone(item)) return true;
const mine = auth.user?.id;
if (!mine) return false;
return (
assigneePeopleOf(item).some((person) => person.id === mine) &&
!item.completedByIds?.includes(mine)
);
}
function completeLabel(item: ActivityDto) {
return needsEveryone(item) ? 'انجام سهم من' : 'انجام شد';
}
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;
openCol.value = null;
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 canDeleteActivity(item: ActivityDto | null | undefined) {
return canMutateActivity(item, auth.user);
}
function activityById(id: string) {
return (
items.value.find((row) => row.id === id) ||
(boardMenuActivity.value?.id === id ? boardMenuActivity.value : null)
);
}
function askDeleteActivity(id: string, title?: string) {
menuId.value = null;
boardMenuId.value = null;
const target = activityById(id);
boardMenuActivity.value = null;
if (target && !canDeleteActivity(target)) {
message.warning('شما مجوز حذف این فعالیت را ندارید.');
error.value = 'شما مجوز حذف این فعالیت را ندارید.';
return;
}
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;
const selected = [...selectedIds.value];
const allowed = selected.filter((id) => {
const row = activityById(id);
return !row || canDeleteActivity(row);
});
const blocked = selected.length - allowed.length;
if (!allowed.length) {
message.warning('شما مجوز حذف این فعالیت‌ها را ندارید.');
error.value = 'شما مجوز حذف این فعالیت‌ها را ندارید.';
return;
}
if (blocked > 0) {
message.warning(
`${toFaDigits(blocked)} مورد به‌خاطر نبود مجوز حذف کنار گذاشته شد.`,
);
}
deleteSingleTitle.value = '';
deleteActivityIds.value = allowed;
}
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');
deleteActivityIds.value = [];
clearSelect();
kanbanReload.value += 1;
await refreshAll();
if (failed.length) {
const first = failed[0];
const detail =
first && first.status === 'rejected'
? apiError(first.reason, '')
: '';
error.value =
failed.length === ids.length
? detail || 'شما مجوز حذف این فعالیت را ندارید.'
: detail
? `${toFaDigits(failed.length)} مورد حذف نشد: ${detail}`
: `${toFaDigits(failed.length)} مورد حذف نشد.`;
message.error(error.value);
}
} finally {
activityDeleting.value = false;
}
}
function onBoardMenu(activity: ActivityDto, stageId?: string) {
boardMenuId.value = boardMenuId.value === activity.id ? null : activity.id;
boardMenuActivity.value = boardMenuId.value ? activity : null;
boardMenuStageId.value = boardMenuId.value ? stageId || activity.stageId || '' : '';
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;
boardMenuStageId.value = '';
},
);
async function exportExcel() {
busy.value = true;
pageBusy.value = true;
busyMessage.value = 'در حال تهیه خروجی اکسل…';
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;
}
}
function onImportStart() {
busy.value = true;
pageBusy.value = false;
importMsg.value = '';
error.value = '';
}
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(' | ');
}
kanbanReload.value += 1;
await refreshAll();
}
function onImportFail(message: string) {
error.value = message;
}
function onImportSettled() {
busy.value = false;
pageBusy.value = false;
}
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.title,
route.query.customer,
route.query.due,
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;
}
const timeChipsEl = ref<HTMLElement | null>(null);
function revealActiveTimeChip() {
if (!isMobile.value) return;
const root = timeChipsEl.value;
const active = root?.querySelector<HTMLElement>('button.on');
if (!root || !active) return;
const rootRect = root.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
const fullyVisible =
activeRect.left >= rootRect.left - 1 && activeRect.right <= rootRect.right + 1;
if (fullyVisible) return;
const delta =
activeRect.left < rootRect.left
? activeRect.left - rootRect.left
: activeRect.right - rootRect.right;
root.scrollBy({ left: delta });
}
let mobileQuery: MediaQueryList | null = null;
function syncMobile() {
isMobile.value = mobileQuery?.matches ?? false;
void nextTick(revealActiveTimeChip);
}
onMounted(() => {
mobileQuery = window.matchMedia('(max-width: 899px)');
syncMobile();
mobileQuery.addEventListener('change', syncMobile);
void loadUsers();
void activityTypes.load(true);
void activityPriorities.load();
void loadBoards()
.then(() => loadStages())
.then(() => {
if (migrateLegacyDoneFilter()) return;
void load();
void loadCounts();
});
});
watch(viewMode, () => {
clearSelect();
boardMenuId.value = null;
boardMenuActivity.value = null;
menuId.value = null;
});
watch(filter, () => {
void nextTick(revealActiveTimeChip);
});
watch(counts, () => {
void nextTick(revealActiveTimeChip);
});
onUnmounted(() => {
mobileQuery?.removeEventListener('change', syncMobile);
unbindBoardMenuDismiss();
});
</script>
<template>
<section class="page" @click="closePopovers">
<UiBusyOverlay :open="pageBusy" :message="busyMessage" />
<div class="desk-chrome">
<div class="desk-top">
<div class="desk-title">
<h1>میز کار و فعالیت‌ها</h1>
<p class="lead">
<nav class="crumbs" aria-label="مسیر">
<RouterLink :to="{ name: 'home' }">خانه</RouterLink>
<span>‹</span>
<span>میز کار</span>
</nav>
<span class="lead-sep" aria-hidden="true">·</span>
<span>کارهای امروز، ارجاع به همکار و پیگیری وضعیت.</span>
</p>
</div>
<div class="desk-actions">
<div class="desk-actions-desktop">
<UiButton
v-if="canManageStages"
class="manage-btn"
variant="ghost"
size="sm"
type="button"
title="انواع فعالیت و اولویت ها"
@click="typesManagerOpen = true"
>
<UiIcon name="sliders" :size="14" />
<span class="manage-label">انواع فعالیت و اولویت ها</span>
</UiButton>
<UiButton
v-if="canManageStages"
class="manage-btn"
variant="ghost"
size="sm"
type="button"
title="مدیریت ستون‌ها"
@click="stagesManagerOpen = true"
>
<UiIcon name="settings" :size="14" />
<span class="manage-label">مدیریت ستون‌ها</span>
</UiButton>
<UiButton
class="excel-btn"
variant="ghost"
size="sm"
type="button"
title="خروجی اکسل"
aria-label="خروجی اکسل"
:disabled="busy"
:loading="pageBusy"
@click.stop="exportExcel"
>
<UiIcon class="excel-icon" name="download" :size="16" />
<span class="btn-full">خروجی اکسل</span>
</UiButton>
<ExcelImportFlow
preview-path="/activities/import/preview"
import-path="/activities/import"
rows-import-path="/activities/import/rows"
:client-aliases="ACTIVITY_EXCEL_ALIASES"
:client-fields="ACTIVITY_EXCEL_FIELDS"
:client-read-options="activityClientReadOptions"
:disabled="busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<template #default="{ loading }">
<UiButton
class="excel-btn"
variant="ghost"
size="sm"
type="button"
title="ورود از اکسل"
aria-label="ورود از اکسل"
:disabled="busy"
:loading="loading"
>
<UiIcon class="excel-icon" name="upload" :size="16" />
<span class="btn-full">ورود از اکسل</span>
</UiButton>
</template>
</ExcelImportFlow>
<UiButton
variant="primary"
size="sm"
class="icon-only"
title="فعالیت جدید"
aria-label="فعالیت جدید"
@click="activityModal = {}"
>
<UiIcon name="plus" :size="16" />
</UiButton>
<RouterLink :to="{ name: 'activities-calendar' }">
<UiButton
variant="ghost"
size="sm"
class="icon-only"
title="تقویم"
aria-label="تقویم"
>
<UiIcon name="calendar" :size="16" />
</UiButton>
</RouterLink>
<div class="view-toggle" role="group" aria-label="نوع نما">
<button
type="button"
class="seg"
:class="{ on: viewMode === 'tabs' }"
title="نمای تب"
aria-label="نمای تب"
:aria-pressed="viewMode === 'tabs'"
@click="setViewMode('tabs')"
>
<UiIcon name="list" :size="16" />
</button>
<button
type="button"
class="seg"
:class="{ on: viewMode === 'board' }"
title="نمای ستونی"
aria-label="نمای ستونی"
:aria-pressed="viewMode === 'board'"
@click="setViewMode('board')"
>
<UiIcon name="grid" :size="16" />
</button>
</div>
</div>
<div class="actions-more">
<UiDropdownMenu
:open="actionsMenuOpen"
align="start"
@update:open="(v) => (actionsMenuOpen = v)"
>
<template #trigger="{ toggle }">
<button
type="button"
class="more-trigger"
aria-label="گزینه‌های میز کار"
title="گزینه‌های میز کار"
@click.stop="toggle"
>
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<div class="actions-menu">
<button type="button" class="menu-row" @click="activityModal = {}">
<UiIcon name="plus" :size="16" />
فعالیت جدید
</button>
<RouterLink class="menu-row" :to="{ name: 'activities-calendar' }">
<UiIcon name="calendar" :size="16" />
تقویم
</RouterLink>
<button
type="button"
class="menu-row"
:class="{ on: viewMode === 'tabs' }"
@click="setViewMode('tabs')"
>
<UiIcon name="list" :size="16" />
نمای تب
</button>
<button
type="button"
class="menu-row"
:class="{ on: viewMode === 'board' }"
@click="setViewMode('board')"
>
<UiIcon name="grid" :size="16" />
نمای ستونی
</button>
<button type="button" class="menu-row" :disabled="busy" @click="exportExcel">
<UiIcon name="download" :size="16" />
خروجی اکسل
</button>
<button type="button" class="menu-row" :disabled="busy" @click="triggerImport">
<UiIcon name="upload" :size="16" />
ورود از اکسل
</button>
<template v-if="canManageStages">
<hr class="board-menu-sep" />
<button type="button" class="menu-row" @click="typesManagerOpen = true">
<UiIcon name="sliders" :size="16" />
انواع فعالیت و اولویت ها
</button>
<button type="button" class="menu-row" @click="stagesManagerOpen = true">
<UiIcon name="settings" :size="16" />
مدیریت ستون‌ها
</button>
</template>
</div>
</UiDropdownMenu>
</div>
<div v-if="isMobile" class="import-proxy" aria-hidden="true">
<ExcelImportFlow
preview-path="/activities/import/preview"
import-path="/activities/import"
rows-import-path="/activities/import/rows"
:client-aliases="ACTIVITY_EXCEL_ALIASES"
:client-fields="ACTIVITY_EXCEL_FIELDS"
:client-read-options="activityClientReadOptions"
:disabled="busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<template #default="{ loading }">
<button ref="importBtn" type="button" tabindex="-1" :disabled="busy || loading">
ورود از اکسل
</button>
</template>
</ExcelImportFlow>
</div>
</div>
</div>
<div class="desk-break" aria-hidden="true"></div>
<div class="desk-bar" :class="{ 'has-tabs': showDeskTabs }">
<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="tools">
<form class="search" @submit.prevent="onSearch">
<UiIcon name="search" :size="16" />
<input
v-model="q"
type="search"
:placeholder="isMobile ? 'جستجو…' : 'جستجو در عنوان، مشتری، کارشناس، نوع فعالیت...'"
/>
</form>
<div class="filter-wrap" @click.stop>
<button
class="btn ghost filter-btn"
type="button"
:class="{ on: activeFacetCount > 0 }"
:title="filterBtnLabel"
:aria-label="filterBtnLabel"
@click="toggleFilter"
>
<UiIcon name="filter" :size="15" />
<span class="filter-label">{{ filterBtnLabel }}</span>
<UiIcon name="chevron" :size="13" class="chev" />
</button>
<div v-if="filterOpen" class="popover filter-popover">
<ActivityDeskFilters
field="all"
:title="titleFilter"
:customer="customerFilter"
:due="dueFilter"
:type-key="typeFilter"
:priority-key="priorityFilter"
:status-key="statusFilter"
:assignee-id="assigneeFilter"
:show-assignee="showDeskTabs"
:users="users"
:types="TYPE_FILTERS"
:priorities="PRIORITY_FILTERS"
:statuses="STATUS_FILTERS"
@title="setTitleFilter"
@customer="setCustomerFilter"
@due="setDueFilter"
@type="setTypeFilter"
@priority="setPriorityFilter"
@status="setStatusFilter"
@assignee="setAssigneeFilter"
@clear="clearFacets"
/>
</div>
</div>
</div>
<div ref="timeChipsEl" 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>
<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>
</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>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="notice" class="ok">{{ notice }}</p>
<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)"
@notice="(msg: string) => (notice = 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">
<div v-if="colOn(col.key)" class="th-cell" @click.stop>
<span>{{ col.label }}</span>
<button
v-if="col.key !== 'who' || showDeskTabs"
type="button"
class="th-filter"
:class="{ on: colFiltered(col.key) }"
:aria-label="`فیلتر ${col.label}`"
:aria-expanded="openCol === col.key"
@click="toggleCol(col.key)"
>
<UiIcon name="filter" :size="12" />
</button>
<div
v-if="openCol === col.key"
class="popover col-popover"
:class="{ 'align-end': col.key === 'when' || col.key === 'status' }"
>
<ActivityDeskFilters
:field="col.key"
:title="titleFilter"
:customer="customerFilter"
:due="dueFilter"
:type-key="typeFilter"
:priority-key="priorityFilter"
:status-key="statusFilter"
:assignee-id="assigneeFilter"
:show-assignee="showDeskTabs"
:users="users"
:types="TYPE_FILTERS"
:priorities="PRIORITY_FILTERS"
:statuses="STATUS_FILTERS"
@title="setTitleFilter"
@customer="setCustomerFilter"
@due="setDueFilter"
@type="setTypeFilter"
@priority="setPriorityFilter"
@status="setStatusFilter"
@assignee="setAssigneeFilter"
@done="onFacetDone"
/>
</div>
</div>
</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="activityRoute(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="activityRoute(item.id)">
مشاهده
</RouterLink>
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
انتقال به کار جاری
</button>
<button v-if="canMarkShare(item)" type="button" @click="complete(item.id)">
{{ completeLabel(item) }}
</button>
<button
v-if="canDeleteActivity(item)"
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="activityRoute(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="activityRoute(item.id)">
مشاهده
</RouterLink>
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
انتقال به کار جاری
</button>
<button v-if="canMarkShare(item)" type="button" @click="complete(item.id)">
{{ completeLabel(item) }}
</button>
<button
v-if="canDeleteActivity(item)"
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="activityRoute(boardMenuId, viewMode === 'board' ? boardMenuStageId : undefined)"
@click="boardMenuId = null; boardMenuActivity = null; boardMenuStageId = ''"
>
مشاهده
</RouterLink>
<button
v-if="boardMenuActivity && canPromotePrior(boardMenuActivity)"
type="button"
role="menuitem"
@click="promotePriorActivity(boardMenuActivity.id)"
>
انتقال به کار جاری
</button>
<button
v-if="canDeleteActivity(boardMenuActivity)"
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 {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
min-height: 0;
height: 100%;
}
.desk-chrome {
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
min-width: 0;
}
.desk-top {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px 10px;
flex-wrap: nowrap;
min-width: 0;
}
.desk-title {
min-width: 0;
flex: 0 1 auto;
}
.desk-title h1 {
margin: 0;
font-size: 1.15rem;
font-weight: 800;
line-height: 1.25;
}
.desk-title .lead {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.35rem;
margin: 0;
color: #64748b;
font-size: 0.75rem;
line-height: 1.35;
min-width: 0;
overflow: hidden;
}
.desk-title .lead > span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lead-sep {
color: #cbd5e1;
}
.desk-actions {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 6px;
margin-inline-start: auto;
flex: 0 1 auto;
min-width: 0;
}
.desk-actions :deep(.icon-only) {
width: 30px;
min-width: 30px;
padding: 0;
}
.view-toggle {
display: inline-flex;
align-items: center;
height: 30px;
padding: 2px;
gap: 2px;
background: #e8eef1;
border-radius: var(--radius);
}
.view-toggle .seg {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: #7b8794;
cursor: pointer;
}
.view-toggle .seg.on {
background: #fff;
color: var(--accent-deep);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
}
.desk-bar {
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.desk-bar .tools {
flex: 0 1 340px;
min-width: 200px;
gap: 6px;
}
.desk-bar .search {
flex: 1 1 160px;
max-width: 280px;
height: 30px;
min-height: 30px;
padding: 0 0.55rem;
font-size: 0.78rem;
}
.desk-bar .filter-btn {
height: 30px;
min-height: 30px;
padding: 0 0.55rem;
font-size: 0.78rem;
}
.crumbs {
display: inline-flex;
gap: 0.3rem;
align-items: center;
color: #94a3b8;
font-size: inherit;
}
.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;
}
.ok {
color: #0f766e;
font-weight: 600;
}
.btn-short {
display: none;
}
.head-actions,
.tools {
display: flex;
gap: 0.55rem;
align-items: center;
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;
flex: 0 0 auto;
gap: 0.15rem;
padding: 2px;
border-radius: 8px;
background: #f1f5f9;
width: fit-content;
}
.desk-tabs button {
border: 0;
background: transparent;
color: #64748b;
font: inherit;
font-size: 0.75rem;
font-weight: 700;
padding: 0.22rem 0.55rem;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
}
.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.3rem;
height: 30px;
max-width: 12rem;
padding: 0 0.5rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface, #fff);
color: var(--ink, #0f172a);
font: inherit;
font-size: 0.75rem;
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.25rem;
flex-wrap: wrap;
}
.time-chips button {
display: inline-flex;
align-items: center;
gap: 0.3rem;
height: 30px;
border: 1px solid var(--line);
background: #fff;
color: #475569;
font: inherit;
font-size: 0.75rem;
font-weight: 700;
padding: 0 0.5rem;
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 {
width: min(20rem, calc(100vw - 1.5rem));
max-height: min(70vh, 32rem);
overflow: auto;
padding: 0.7rem 0.75rem 0.55rem;
}
.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) 9.2rem 8.6rem 9.2rem 2.4rem;
gap: 0.8rem;
align-items: center;
padding: 0.95rem 1.1rem;
}
.thead {
position: relative;
z-index: 3;
color: #94a3b8;
font-size: 0.78rem;
font-weight: 600;
border-bottom: 1px solid #f1f5f9;
border-radius: var(--radius) var(--radius) 0 0;
}
.th-cell {
position: relative;
display: flex;
align-items: center;
gap: 0.2rem;
min-width: 0;
}
.th-cell > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.th-filter {
flex: none;
display: grid;
place-items: center;
width: 1.35rem;
height: 1.35rem;
margin: 0;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: #94a3b8;
cursor: pointer;
}
.th-filter:hover,
.th-filter.on,
.th-filter[aria-expanded='true'] {
color: #0f766e;
background: #f0fdfa;
}
.col-popover {
inset-inline-start: 0;
inset-inline-end: auto;
width: 16.5rem;
padding: 0.65rem 0.7rem;
}
.col-popover.align-end {
inset-inline-start: auto;
inset-inline-end: 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;
}
.excel-icon {
display: none;
}
.actions-more {
display: none;
}
.desk-break {
display: none;
}
.desk-actions-desktop {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 6px;
min-width: 0;
}
.more-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
color: #475569;
cursor: pointer;
}
.actions-menu {
display: grid;
gap: 1px;
}
.actions-menu .menu-row {
display: flex;
align-items: center;
gap: 0.45rem;
width: 100%;
border: 0;
background: transparent;
text-align: right;
padding: 0.55rem 0.7rem;
border-radius: var(--radius, 10px);
font: inherit;
font-size: 0.875rem;
color: var(--ink, #0f172a);
cursor: pointer;
text-decoration: none;
}
.actions-menu .menu-row:hover,
.actions-menu .menu-row.on {
background: var(--accent-soft, #ecfdf8);
color: var(--accent-deep, #0f766e);
}
.actions-menu .menu-row:disabled {
opacity: 0.45;
cursor: default;
}
.import-proxy {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
@media (max-width: 899px) {
.crumbs,
.desktop-only,
.tabs button em,
.pager .per {
display: none;
}
.mobile-only {
display: grid;
}
.page-head {
flex-direction: column;
align-items: stretch;
}
.desk-chrome {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
column-gap: 4px;
row-gap: 8px;
}
.desk-top,
.desk-bar {
display: contents;
}
.desk-title {
order: 1;
flex: 1 0 100%;
}
.desk-title .lead {
display: none;
}
.desk-actions-desktop {
display: none;
}
.actions-more {
display: inline-flex;
}
.desk-tabs {
order: 2;
flex: 0 0 auto;
}
.time-chips {
order: 3;
flex: 1 1 0;
width: auto;
}
.desk-bar :deep(.ui-dd-anchor) {
order: 7;
flex: 0 0 auto;
min-width: 0;
max-width: 8.5rem;
}
.desk-break {
display: block;
order: 5;
flex: 1 0 100%;
height: 0;
margin-bottom: -8px;
}
.desk-actions {
order: 8;
flex: 0 0 30px;
width: 30px;
margin: 0;
}
.desk-bar .tools,
.desk-bar:not(.has-tabs) .tools {
order: 6;
flex: 1 1 0;
grid-area: auto;
grid-column: auto;
flex-wrap: nowrap;
align-items: center;
min-width: 0;
width: auto;
gap: 4px;
}
.desk-tabs button {
padding: 0.16rem 0.35rem;
font-size: 0.68rem;
}
.desk-bar .search {
flex: 1 1 auto;
width: auto;
min-width: 0;
max-width: none;
height: 30px;
min-height: 30px;
}
.desk-bar .filter-btn {
flex: 0 0 30px;
width: 30px;
min-width: 30px;
height: 30px;
min-height: 30px;
padding: 0;
gap: 0;
}
.desk-bar .filter-label,
.desk-bar .filter-btn .chev {
display: none;
}
.time-chips {
flex-wrap: nowrap;
min-width: 0;
gap: 2px;
overflow-x: auto;
scrollbar-width: none;
scroll-snap-type: x proximity;
}
.time-chips::-webkit-scrollbar {
display: none;
}
.time-chips button {
flex: 0 0 auto;
height: 28px;
padding: 0 0.25rem;
font-size: 0.68rem;
gap: 0.15rem;
white-space: nowrap;
scroll-snap-align: start;
}
.board-trigger {
width: auto;
max-width: 8.5rem;
padding-inline: 0.35rem;
font-size: 0.75rem;
}
.head-actions {
width: 100%;
}
.head-actions .btn {
flex: 1;
}
.btn-full {
display: none;
}
.btn-short {
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>