Files
CRM_Montzar/apps/web/src/views/ActivitiesView.vue
T
2026-10-31 15:05:05 +03:30

3391 lines
87 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 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,
ImportContactsResult,
OrgUserDto,
PaginatedResponse,
} from '@mento/shared';
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_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 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);
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);
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,
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 activityRoute(id: string, edit = false) {
const query: Record<string, string> = {};
for (const key of ['board', 'view', 'desk', 'filter', 'stage', 'q', 'typeDefId', 'status', 'assigneeId'] as const) {
const value = route.query[key];
if (typeof value === 'string' && value) query[key] = value;
}
return {
name: edit ? ('activity-edit' as const) : ('activity-detail' as const),
params: { id },
query,
};
}
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: 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;
},
);
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.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 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="filterOpen = !filterOpen"
>
<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">
<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>
<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="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="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>
<RouterLink :to="activityRoute(item.id, true)">
ویرایش
</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="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>
<RouterLink :to="activityRoute(item.id, true)">
ویرایش
</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="activityRoute(boardMenuId)"
@click="boardMenuId = null; boardMenuActivity = null"
>
مشاهده
</RouterLink>
<RouterLink
role="menuitem"
:to="activityRoute(boardMenuId, true)"
@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 {
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 {
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;
}
.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>