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>
3653 lines
95 KiB
Vue
3653 lines
95 KiB
Vue
<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>
|