2026-08-29 21:06:30 +03:30
< script setup lang = "ts" >
2026-10-17 13:26:11 +03:30
import { computed , nextTick , onMounted , onUnmounted , ref , watch } from 'vue' ;
2026-08-29 21:06:30 +03:30
import { RouterLink , useRoute , useRouter } from 'vue-router' ;
import axios from 'axios' ;
import { api } from '@/lib/api' ;
2026-09-02 21:29:00 +03:30
import {
calendarDaysBetween ,
formatClockFa ,
formatJalaliDay ,
startOfLocalDay ,
toFaDigits ,
} from '@/lib/jalali' ;
2026-09-12 01:33:07 +03:30
import {
2026-10-03 12:29:11 +03:30
UiBusyOverlay ,
2026-09-12 01:33:07 +03:30
UiButton ,
2026-09-22 11:02:08 +03:30
UiDropdownMenu ,
2026-10-20 17:23:27 +03:30
UiInput ,
2026-09-12 01:33:07 +03:30
UiModal ,
UiPagination ,
} from '@/components/ui' ;
2026-09-02 21:29:00 +03:30
import UiIcon from '@/components/UiIcon.vue' ;
2026-09-19 15:30:31 +03:30
import ExcelImportFlow from '@/components/ExcelImportFlow.vue' ;
2026-10-07 11:18:53 +03:30
import ActivitiesKanban from '@/components/activities/ActivitiesKanban.vue' ;
2026-11-01 21:20:42 +03:30
import ActivityDeskFilters from '@/components/activities/ActivityDeskFilters.vue' ;
2026-10-07 11:18:53 +03:30
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue' ;
2026-10-07 12:29:23 +03:30
import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue' ;
2026-10-07 11:18:53 +03:30
import { PIPELINE_ACCENT } from '@/lib/pipeline-board' ;
2026-10-07 12:29:23 +03:30
import { activityTypeView , typeColorStyle , useActivityTypes } from '@/lib/activity-types' ;
2026-10-04 10:44:00 +03:30
import {
ACTIVITY_EXCEL_ALIASES ,
ACTIVITY_EXCEL_FIELDS ,
DIDAR_ACTIVITY_POSITIONAL ,
looksLikeDidarActivityHeaderless ,
} from '@/lib/activity-excel' ;
const activityClientReadOptions = {
positionalColumns : DIDAR_ACTIVITY_POSITIONAL ,
detectHeaderless : looksLikeDidarActivityHeaderless ,
};
2026-09-10 01:22:24 +03:30
import ActivityFormView from '@/views/ActivityFormView.vue' ;
2026-09-02 21:29:00 +03:30
import type {
2026-10-20 17:23:27 +03:30
ActivityBoardDto ,
2026-09-02 21:29:00 +03:30
ActivityDto ,
ActivityFilter ,
2026-10-07 11:18:53 +03:30
ActivityStageCountsDto ,
ActivityStageDto ,
2026-10-03 12:00:46 +03:30
ActivityStatus ,
2026-09-02 21:29:00 +03:30
ActivityType ,
2026-09-15 17:33:05 +03:30
ImportContactsResult ,
2026-10-03 12:00:46 +03:30
OrgUserDto ,
2026-09-02 21:29:00 +03:30
PaginatedResponse ,
} from '@mento/shared' ;
2026-09-15 17:33:05 +03:30
import { brand } from '@mento/shared' ;
2026-09-28 12:28:14 +03:30
import { useAuthStore } from '@/stores/auth' ;
2026-10-07 11:18:53 +03:30
import { canManageSettings , canViewAllRecords } from '@/lib/roles' ;
2026-10-05 20:00:21 +03:30
import { PAGE_SIZE_OPTIONS , usePageSize } from '@/lib/pageSize' ;
2026-10-06 13:35:13 +03:30
import { registerTableColumns , useTableColumns } from '@/lib/tableColumns' ;
import TableColumnsMenu from '@/components/TableColumnsMenu.vue' ;
2026-10-17 12:47:07 +03:30
import { bindMenuDismiss , cardMenuStyle } from '@/lib/anchored-menu' ;
2026-10-06 13:35:13 +03:30
const ACT_TABLE_KEY = 'activities' ;
const ACT_COLUMNS = [
2026-10-07 12:29:23 +03:30
{ key : 'title' , label : 'عنوان فعالیت' , width : 'minmax(0, 2fr)' },
{ key : 'who' , label : 'کارشناس' , width : 'minmax(7rem, 0.9fr)' },
{ key : 'customer' , label : 'مشتری' , width : 'minmax(7rem, 1fr)' },
2026-11-01 21:20:42 +03:30
{ key : 'type' , label : 'نوع فعالیت' , width : '9.2rem' },
{ key : 'when' , label : 'زمان شروع' , width : '8.6rem' },
{ key : 'status' , label : 'وضعیت' , width : '9.2rem' },
2026-10-06 13:35:13 +03:30
] as const ;
type ActColKey = ( typeof ACT_COLUMNS )[ number ][ 'key' ];
registerTableColumns (
ACT_TABLE_KEY ,
ACT_COLUMNS . map (( c ) => c . label ),
);
const actCols = useTableColumns (() => ACT_TABLE_KEY );
function colOn ( key : ActColKey ) {
const col = ACT_COLUMNS . find (( c ) => c . key === key ) ! ;
return actCols . isVisible ( col . label );
}
2026-10-17 12:47:07 +03:30
const selectMode = ref ( false );
const selectedIds = ref < Set < string > > ( new Set ());
const selectedCount = computed (() => selectedIds . value . size );
2026-10-06 13:35:13 +03:30
const actGridStyle = computed (() => ({
gridTemplateColumns : [
'2.4rem' ,
2026-10-17 12:47:07 +03:30
...( selectMode . value ? [ '2rem' ] : []),
2026-10-06 13:35:13 +03:30
... ACT_COLUMNS . filter (( c ) => actCols . isVisible ( c . label )). map (( c ) => c . width ),
'2.4rem' ,
]. join ( ' ' ),
}));
2026-09-02 21:29:00 +03:30
2026-10-07 12:29:23 +03:30
/** 'all' یا شناسهٔ نوع فعالیت */
type TypeFilter = string ;
2026-10-03 12:00:46 +03:30
type StatusFilter = 'all' | ActivityStatus ;
2026-09-02 21:29:00 +03:30
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled' ;
2026-09-28 12:28:14 +03:30
type DeskScope = 'mine' | 'team' ;
2026-10-07 11:18:53 +03:30
type ViewMode = 'tabs' | 'board' ;
type TimeFilter = Exclude < ActivityFilter , ' done ' >;
2026-09-02 21:29:00 +03:30
2026-10-07 11:18:53 +03:30
const TIME_TABS : Array < { key : TimeFilter ; label : string } > = [
2026-09-02 21:29:00 +03:30
{ key : 'today' , label : 'امروز' },
{ key : 'overdue' , label : 'عقبافتاده' },
{ key : 'upcoming' , label : 'بعداً' },
2026-10-07 11:18:53 +03:30
{ key : 'all' , label : 'همه زمانها' },
2026-09-02 21:29:00 +03:30
];
2026-10-07 11:18:53 +03:30
const TIME_KEYS : TimeFilter [] = TIME_TABS . map (( t ) => t . key );
const VIEW_STORAGE_KEY = 'activities-view-mode' ;
2026-09-02 21:29:00 +03:30
2026-09-28 12:28:14 +03:30
const DESK_TABS : Array < { key : DeskScope ; label : string } > = [
{ key : 'mine' , label : 'میز من' },
{ key : 'team' , label : 'همه تیم' },
];
2026-10-03 12:00:46 +03:30
const STATUS_FILTERS : Array < { key : StatusFilter ; label : string } > = [
{ key : 'all' , label : 'همه وضعیتها' },
{ key : 'open' , label : 'باز / انجامنشده' },
{ key : 'done' , label : 'انجامشده' },
{ key : 'canceled' , label : 'لغو شده' },
];
2026-10-05 20:00:21 +03:30
const pageSize = usePageSize ( 'activities' , 10 );
2026-08-29 21:06:30 +03:30
const route = useRoute ();
const router = useRouter ();
2026-09-28 12:28:14 +03:30
const auth = useAuthStore ();
const showDeskTabs = computed (() => canViewAllRecords ( auth . user ? . role ));
2026-10-07 11:18:53 +03:30
const canManageStages = computed (() => canManageSettings ( auth . user ? . role ));
2026-08-29 21:06:30 +03:30
2026-10-07 11:18:53 +03:30
function storedViewMode () : ViewMode {
try {
return localStorage . getItem ( VIEW_STORAGE_KEY ) === 'board' ? 'board' : 'tabs' ;
} catch {
return 'tabs' ;
2026-08-29 21:06:30 +03:30
}
2026-10-07 11:18:53 +03:30
}
const viewMode = computed < ViewMode >(() => {
if ( route . query . view === 'board' ) return 'board' ;
if ( route . query . view === 'tabs' ) return 'tabs' ;
return storedViewMode ();
});
/** پیشفرض زمانی: در نمای تب «امروز»، در نمای ستونی «همه زمانها» */
const defaultTime = computed < TimeFilter >(() => ( viewMode . value === 'board' ? 'all' : 'today' ));
const filter = computed < TimeFilter >(() => {
const value = typeof route . query . filter === 'string' ? route . query . filter : '' ;
if ( TIME_KEYS . includes ( value as TimeFilter )) return value as TimeFilter ;
if ( value === 'done' ) return 'all' ;
return defaultTime . value ;
});
const stages = ref < ActivityStageDto [] >([]);
const stagesManagerOpen = ref ( false );
2026-10-07 12:29:23 +03:30
const typesManagerOpen = ref ( false );
2026-10-20 17:23:27 +03:30
/** گروههای فعالیت (مثل مسیر فروش)؛ هر گروه ستونهای خودش را دارد */
const boards = ref < ActivityBoardDto [] >([]);
const defaultBoardId = computed (() => boards . value . find (( b ) => b . isDefault ) ? . id ?? '' );
const board = computed < string >(() => {
const value = typeof route . query . board === 'string' ? route . query . board : '' ;
if ( value && boards . value . some (( b ) => b . id === value )) return value ;
return defaultBoardId . value ;
});
const currentBoard = computed (() => boards . value . find (( b ) => b . id === board . value ) ?? null );
2026-10-20 17:59:34 +03:30
const boardMenuOpen = ref ( false );
2026-10-31 15:05:05 +03:30
const actionsMenuOpen = ref ( false );
const importBtn = ref < HTMLButtonElement | null >( null );
function triggerImport () {
importBtn . value ? . click ();
}
2026-10-28 20:24:40 +03:30
const boardModal = ref < { mode : 'create' | 'edit' ; name : string ; memberIds : string [] } | null > ( null );
const boardMemberQuery = ref ( '' );
2026-10-20 17:23:27 +03:30
const boardSaving = ref ( false );
const boardDeleteOpen = ref ( false );
2026-10-07 12:29:23 +03:30
async function onTypesChanged () {
await activityTypes . load ( true );
kanbanReload . value += 1 ;
await refreshAll ();
}
2026-10-07 11:18:53 +03:30
const kanbanReload = ref ( 0 );
const stageCounts = ref < ActivityStageCountsDto >({ counts : {}, total : 0 });
const stage = computed < string >(() => {
const value = typeof route . query . stage === 'string' ? route . query . stage : 'all' ;
if ( value === 'all' || stages . value . some (( s ) => s . id === value )) return value ;
return 'all' ;
2026-08-29 21:06:30 +03:30
});
2026-09-28 12:28:14 +03:30
const desk = computed < DeskScope >(() => {
if ( ! showDeskTabs . value ) return 'mine' ;
return route . query . desk === 'team' ? 'team' : 'mine' ;
});
2026-09-02 21:29:00 +03:30
const page = computed (() => {
const raw = Number ( route . query . page );
return Number . isFinite ( raw ) && raw > 0 ? raw : 1 ;
});
2026-08-29 21:06:30 +03:30
const loading = ref ( false );
const error = ref ( '' );
const data = ref < PaginatedResponse < ActivityDto > | null > ( null );
const q = ref ( typeof route . query . q === 'string' ? route . query . q : '' );
2026-10-03 12:00:46 +03:30
const users = ref < OrgUserDto [] >([]);
2026-09-02 21:29:00 +03:30
const filterOpen = ref ( false );
2026-11-01 21:20:42 +03:30
const openCol = ref < ActColKey | null >( null );
2026-09-02 21:29:00 +03:30
const ideasOpen = ref ( false );
2026-10-03 12:00:46 +03:30
const ACTIVITY_TYPES : ActivityType [] = [ 'call' , 'meeting' , 'task' , 'note' ];
const ACTIVITY_STATUSES : ActivityStatus [] = [ 'open' , 'done' , 'canceled' ];
2026-10-07 12:29:23 +03:30
const activityTypes = useActivityTypes ();
const TYPE_FILTERS = computed < Array < { key : TypeFilter ; label : string }> > (() => [
{ key : 'all' , label : 'همه انواع' },
... activityTypes . types . value
. filter (( t ) => t . isActive || t . id === typeFilter . value )
. map (( t ) => ({ key : t . id , label : t . name })),
]);
/** پارامتر قدیمی ?type=call → نوع اصلیِ همان نوع پایه */
const legacyBaseType = computed < ActivityType | null >(() => {
const value = typeof route . query . type === 'string' ? route . query . type : '' ;
return ACTIVITY_TYPES . includes ( value as ActivityType ) ? ( value as ActivityType ) : null ;
});
2026-10-03 12:00:46 +03:30
const typeFilter = computed < TypeFilter >(() => {
2026-10-07 12:29:23 +03:30
const id = typeof route . query . typeDefId === 'string' ? route . query . typeDefId . trim () : '' ;
if ( id ) return id ;
if ( legacyBaseType . value ) return activityTypes . systemOf . value [ legacyBaseType . value ] ? . id ?? 'all' ;
return 'all' ;
2026-10-03 12:00:46 +03:30
});
const statusFilter = computed < StatusFilter >(() => {
const value = typeof route . query . status === 'string' ? route . query . status : 'all' ;
return ACTIVITY_STATUSES . includes ( value as ActivityStatus )
? ( value as ActivityStatus )
: 'all' ;
});
const assigneeFilter = computed (() =>
typeof route . query . assigneeId === 'string' ? route . query . assigneeId : '' ,
);
2026-11-01 21:20:42 +03:30
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 : '' ;
});
2026-09-10 01:22:24 +03:30
const activityModal = ref < {
type ?: ActivityType ;
title ?: string ;
2026-10-07 11:18:53 +03:30
stageId ?: string ;
2026-09-10 01:22:24 +03:30
} | null > ( null );
2026-09-02 21:29:00 +03:30
const menuId = ref < string | null >( null );
2026-10-17 12:47:07 +03:30
const boardMenuId = ref < string | null >( null );
2026-10-17 17:28:57 +03:30
const boardMenuActivity = ref < ActivityDto | null >( null );
2026-10-17 12:47:07 +03:30
const boardMenuStyle = ref < Record < string , string > | null > ( null );
const deleteActivityIds = ref < string [] >([]);
const deleteSingleTitle = ref ( '' );
const activityDeleting = ref ( false );
2026-09-15 17:33:05 +03:30
const busy = ref ( false );
2026-10-03 12:29:11 +03:30
const pageBusy = ref ( false );
const busyMessage = ref ( 'لطفاً صبر کنید…' );
2026-09-15 17:33:05 +03:30
const importMsg = ref ( '' );
2026-10-07 11:18:53 +03:30
const counts = ref < Record < TimeFilter , number > > ({
2026-09-02 21:29:00 +03:30
today : 0 ,
overdue : 0 ,
upcoming : 0 ,
all : 0 ,
});
2026-08-29 21:06:30 +03:30
2026-09-02 21:29:00 +03:30
const statusLabel : Record < UiStatus , string > = {
done : 'انجام شد' ,
progress : 'در حال انجام' ,
planned : 'برنامهریزی شده' ,
overdue : 'عقبافتاده' ,
canceled : 'لغو شده' ,
};
const statusIcon : Record < UiStatus , ' check ' | ' clock ' | ' calendar ' | ' warning ' > = {
done : 'check' ,
progress : 'clock' ,
planned : 'calendar' ,
overdue : 'warning' ,
canceled : 'warning' ,
};
2026-10-03 12:00:46 +03:30
const items = computed (() => data . value ? . items ?? []);
2026-09-02 21:29:00 +03:30
const total = computed (() => data . value ? . total ?? 0 );
2026-10-05 20:00:21 +03:30
const pageCount = computed (() => Math . max ( 1 , Math . ceil ( total . value / pageSize . value )));
2026-10-07 11:18:53 +03:30
const stageById = computed (() => new Map ( stages . value . map (( s ) => [ s . id , s ])));
const filterLabel = computed (() => {
const time = TIME_TABS . find (( t ) => t . key === filter . value ) ? . label ?? 'امروز' ;
const st = stage . value === 'all' ? 'همه ستونها' : stageById . value . get ( stage . value ) ? . name ?? '' ;
return ` ${ st } · ${ time } ` ;
});
/** فیلترهای مشترک لیست، شمارشها و کانبان */
const sharedParams = computed < Record < string , string | undefined > > (() => ({
2026-10-20 17:23:27 +03:30
boardId : board . value || undefined ,
2026-10-07 11:18:53 +03:30
filter : filter . value ,
q : typeof route . query . q === 'string' && route . query . q . trim () ? route . query . q . trim () : undefined ,
desk : showDeskTabs . value ? desk . value : undefined ,
2026-10-07 12:29:23 +03:30
typeDefId : typeof route . query . typeDefId === 'string' && route . query . typeDefId . trim ()
? route . query . typeDefId . trim ()
: undefined ,
type :
! ( typeof route . query . typeDefId === 'string' && route . query . typeDefId . trim ()) && legacyBaseType . value
? legacyBaseType . value
: undefined ,
2026-10-07 11:18:53 +03:30
status : statusFilter . value === 'all' ? undefined : statusFilter . value ,
assigneeId : assigneeFilter . value || undefined ,
2026-11-01 21:20:42 +03:30
title : titleFilter . value . trim () || undefined ,
customer : customerFilter . value . trim () || undefined ,
due : dueFilter . value || undefined ,
2026-10-07 11:18:53 +03:30
}));
function stageDotStyle ( s : ActivityStageDto ) {
return { background : PIPELINE_ACCENT [ s . color ]. solid };
}
2026-10-03 12:00:46 +03:30
const activeFacetCount = computed (() => {
let n = 0 ;
if ( typeFilter . value !== 'all' ) n += 1 ;
if ( statusFilter . value !== 'all' ) n += 1 ;
if ( assigneeFilter . value ) n += 1 ;
2026-11-01 21:20:42 +03:30
if ( titleFilter . value . trim ()) n += 1 ;
if ( customerFilter . value . trim ()) n += 1 ;
if ( dueFilter . value ) n += 1 ;
2026-10-03 12:00:46 +03:30
return n ;
});
const filterBtnLabel = computed (() =>
activeFacetCount . value ? `فیلتر ( ${ toFaDigits ( activeFacetCount . value ) } )` : 'فیلتر' ,
);
2026-10-07 11:18:53 +03:30
const trulyEmpty = computed (
() =>
! loading . value &&
counts . value . all === 0 &&
stage . value === 'all' &&
! q . value . trim () &&
! activeFacetCount . value ,
);
2026-09-02 21:29:00 +03:30
const listEmpty = computed (() => ! loading . value && ! items . value . length );
function isOverdue ( item : ActivityDto ) {
if ( item . status !== 'open' || ! item . dueAt ) return false ;
return startOfLocalDay ( new Date ( item . dueAt )). getTime () < startOfLocalDay (). getTime ();
}
2026-09-23 16:32:41 +03:30
const isMobile = ref ( false );
function cardStatusText ( item : ActivityDto ) {
if ( item . status === 'done' ) return 'انجام شد' ;
if ( item . status === 'canceled' ) return 'لغو شده' ;
return 'انجام نشده' ;
}
2026-09-02 21:29:00 +03:30
function uiStatus ( item : ActivityDto ) : UiStatus {
if ( item . status === 'done' ) return 'done' ;
if ( item . status === 'canceled' ) return 'canceled' ;
if ( isOverdue ( item )) return 'overdue' ;
if ( item . dueAt && calendarDaysBetween ( new Date ( item . dueAt ), new Date ()) === 0 ) {
return 'progress' ;
2026-08-29 21:06:30 +03:30
}
2026-09-02 21:29:00 +03:30
return 'planned' ;
}
function clockText ( item : ActivityDto ) {
if ( ! item . dueAt ) return '—' ;
return formatClockFa ( new Date ( item . dueAt ));
}
function dayText ( item : ActivityDto ) {
if ( ! item . dueAt ) return 'بدون موعد' ;
const days = calendarDaysBetween ( new Date ( item . dueAt ), new Date ());
if ( days === 0 ) return 'امروز' ;
if ( days === 1 ) return 'دیروز' ;
if ( days === - 1 ) return 'فردا' ;
return formatJalaliDay ( new Date ( item . dueAt ));
}
function dateText ( item : ActivityDto ) {
if ( ! item . dueAt ) return 'بدون موعد' ;
return formatJalaliDay ( new Date ( item . dueAt ));
}
function subtitle ( item : ActivityDto ) {
2026-10-11 16:48:37 +03:30
const linked = item . deal ? . title || item . lead ? . title || item . person ? . fullName ;
if ( linked ) return linked ;
return ( item . description ?? '' ). split ( '\n' )[ 0 ]. trim ();
2026-09-02 21:29:00 +03:30
}
2026-10-20 17:23:27 +03:30
async function loadBoards () {
try {
const { data : rows } = await api . get < ActivityBoardDto [] >( '/activities/boards' );
boards . value = rows ;
} catch {
error . value = 'بارگذاری گروههای فعالیت ناموفق بود.' ;
}
}
2026-10-07 11:18:53 +03:30
async function loadStages () {
try {
2026-10-20 17:23:27 +03:30
const { data : rows } = await api . get < ActivityStageDto [] >( '/activities/stages' , {
params : { boardId : board . value || undefined },
});
2026-10-07 11:18:53 +03:30
stages . value = rows ;
} catch {
error . value = 'بارگذاری ستونهای فعالیت ناموفق بود.' ;
}
}
2026-10-29 09:22:23 +03:30
function activityRoute ( id : string , edit = false ) {
const query : Record < string , string > = {};
2026-11-01 21:20:42 +03:30
for ( const key of [ 'board' , 'view' , 'desk' , 'filter' , 'stage' , 'q' , 'typeDefId' , 'status' , 'assigneeId' , 'title' , 'customer' , 'due' ] as const ) {
2026-10-29 09:22:23 +03:30
const value = route . query [ key ];
if ( typeof value === 'string' && value ) query [ key ] = value ;
}
return {
name : edit ? ( 'activity-edit' as const ) : ( 'activity-detail' as const ),
params : { id },
query ,
};
}
2026-10-20 17:23:27 +03:30
function setBoard ( next : string ) {
void router . replace ({
name : 'activities' ,
query : {
... route . query ,
board : next && next !== defaultBoardId . value ? next : undefined ,
stage : undefined ,
page : undefined ,
},
});
}
2026-10-28 20:24:40 +03:30
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 ;
}
2026-10-20 17:23:27 +03:30
function openCreateBoard () {
2026-10-28 20:24:40 +03:30
boardMemberQuery . value = '' ;
boardModal . value = { mode : 'create' , name : '' , memberIds : [] };
void ensureBoardUsers ();
2026-10-20 17:23:27 +03:30
}
function openEditBoard () {
if ( ! currentBoard . value ) return ;
2026-10-28 20:24:40 +03:30
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 ];
2026-10-20 17:23:27 +03:30
}
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' ) {
2026-10-28 20:24:40 +03:30
const { data : created } = await api . post < ActivityBoardDto >( '/activities/boards' , {
name ,
memberIds : memberIdsToSave ( modal . memberIds ),
});
2026-10-20 17:23:27 +03:30
await loadBoards ();
boardModal . value = null ;
setBoard ( created . id );
} else if ( currentBoard . value ) {
2026-10-28 20:24:40 +03:30
await api . patch ( `/activities/boards/ ${ currentBoard . value . id } ` , {
name ,
memberIds : memberIdsToSave ( modal . memberIds ),
});
2026-10-20 17:23:27 +03:30
await loadBoards ();
boardModal . value = null ;
}
} catch ( e ) {
error . value = axios . isAxiosError ( e )
? String (( e . response ? . data as { message ?: string }) ? . message ?? 'ذخیرهٔ گروه ناموفق بود.' )
: 'ذخیرهٔ گروه ناموفق بود.' ;
} finally {
boardSaving . value = false ;
}
}
async function deleteBoard () {
const target = currentBoard . value ;
if ( ! target || target . isDefault || boardSaving . value ) return ;
boardSaving . value = true ;
error . value = '' ;
try {
await api . delete ( `/activities/boards/ ${ target . id } ` );
boardDeleteOpen . value = false ;
boardModal . value = null ;
await loadBoards ();
setBoard ( defaultBoardId . value );
} catch {
error . value = 'حذف گروه ناموفق بود.' ;
} finally {
boardSaving . value = false ;
}
}
2026-10-07 11:18:53 +03:30
/** شمارش تبهای زمانی (با ستون انتخابی در نمای تب) و شمارش ستونها (با زمان انتخابی) */
2026-09-02 21:29:00 +03:30
async function loadCounts () {
2026-10-07 11:18:53 +03:30
const base = sharedParams . value ;
const stageParam = viewMode . value === 'tabs' ? stage . value : 'all' ;
try {
const [ timeResults , stageRes ] = await Promise . all ([
Promise . all (
TIME_KEYS . map (( key ) =>
api . get < PaginatedResponse < ActivityDto > > ( '/activities' , {
params : { ... base , filter : key , stageId : stageParam , limit : 1 },
}),
),
),
api . get < ActivityStageCountsDto >( '/activities/stage-counts' , { params : base }),
]);
const next = { ... counts . value };
TIME_KEYS . forEach (( key , i ) => {
next [ key ] = timeResults [ i ] ? . data . total ?? 0 ;
});
counts . value = next ;
stageCounts . value = stageRes . data ;
} catch {
/* شمارشها اختیاریاند؛ خطای اصلی را load نشان میدهد */
}
}
async function refreshAll () {
await Promise . all ([ viewMode . value === 'tabs' ? load () : Promise . resolve (), loadCounts ()]);
2026-08-29 21:06:30 +03:30
}
2026-10-03 12:00:46 +03:30
async function loadUsers () {
if ( ! showDeskTabs . value ) {
users . value = [];
return ;
}
try {
const { data : rows } = await api . get < OrgUserDto [] >( '/users' );
users . value = rows . filter (( u ) => u . isActive !== false );
} catch {
users . value = [];
}
}
2026-08-29 21:06:30 +03:30
async function load () {
2026-10-07 11:18:53 +03:30
if ( viewMode . value !== 'tabs' ) return ;
2026-08-29 21:06:30 +03:30
loading . value = true ;
error . value = '' ;
2026-09-02 21:29:00 +03:30
menuId . value = null ;
2026-08-29 21:06:30 +03:30
try {
const { data : res } = await api . get < PaginatedResponse < ActivityDto > > ( '/activities' , {
2026-09-02 21:29:00 +03:30
params : {
2026-10-07 11:18:53 +03:30
... sharedParams . value ,
stageId : stage . value ,
2026-09-02 21:29:00 +03:30
page : page . value ,
2026-10-05 20:00:21 +03:30
limit : pageSize . value ,
2026-09-02 21:29:00 +03:30
},
2026-08-29 21:06:30 +03:30
});
data . value = res ;
} catch ( e ) {
if ( axios . isAxiosError ( e ) && ! e . response ) {
error . value = 'اتصال به API برقرار نشد.' ;
} else {
error . value = 'بارگذاری فعالیتها ناموفق بود.' ;
}
} finally {
loading . value = false ;
}
}
2026-09-28 12:28:14 +03:30
function setDesk ( next : DeskScope ) {
void router . replace ({
name : 'activities' ,
query : {
... route . query ,
desk : next === 'mine' ? undefined : next ,
page : undefined ,
},
});
}
2026-10-07 11:18:53 +03:30
function setFilter ( next : TimeFilter ) {
2026-09-02 21:29:00 +03:30
filterOpen . value = false ;
void router . replace ({
2026-08-29 21:06:30 +03:30
name : 'activities' ,
query : {
... route . query ,
2026-10-07 11:18:53 +03:30
filter : next === defaultTime . value ? undefined : next ,
2026-09-02 21:29:00 +03:30
page : undefined ,
},
});
}
2026-10-07 11:18:53 +03:30
function setStage ( next : string ) {
void router . replace ({
name : 'activities' ,
query : {
... route . query ,
stage : next === 'all' ? undefined : next ,
page : undefined ,
},
});
}
function setViewMode ( next : ViewMode ) {
try {
localStorage . setItem ( VIEW_STORAGE_KEY , next );
} catch {
/* ignore */
}
void router . replace ({
name : 'activities' ,
query : {
... route . query ,
view : next ,
filter : undefined ,
page : undefined ,
},
});
}
function onStagesChanged () {
void loadStages (). then (() => loadCounts ());
}
function openAddInStage ( stageId : string ) {
activityModal . value = { stageId };
}
function onActivitySaved () {
activityModal . value = null ;
kanbanReload . value += 1 ;
void refreshAll ();
}
2026-09-02 21:29:00 +03:30
function setPage ( next : number ) {
const safe = Math . min ( Math . max ( 1 , next ), pageCount . value );
void router . replace ({
name : 'activities' ,
query : {
... route . query ,
page : safe === 1 ? undefined : String ( safe ),
2026-08-29 21:06:30 +03:30
},
});
}
2026-10-05 20:00:21 +03:30
function onPageSize ( size : number ) {
pageSize . value = size ;
if ( page . value === 1 ) void load ();
}
2026-08-29 21:06:30 +03:30
function onSearch () {
2026-09-02 21:29:00 +03:30
void router . replace ({
2026-08-29 21:06:30 +03:30
name : 'activities' ,
query : {
... route . query ,
q : q . value . trim () || undefined ,
2026-09-02 21:29:00 +03:30
page : undefined ,
2026-08-29 21:06:30 +03:30
},
});
}
2026-10-03 12:00:46 +03:30
function patchFacetQuery ( patch : Record < string , string | undefined >) {
void router . replace ({
name : 'activities' ,
query : {
... route . query ,
... patch ,
page : undefined ,
},
});
}
2026-09-02 21:29:00 +03:30
function setTypeFilter ( next : TypeFilter ) {
2026-10-07 12:29:23 +03:30
patchFacetQuery ({ typeDefId : next === 'all' ? undefined : next , type : undefined });
2026-10-03 12:00:46 +03:30
}
2026-11-01 21:20:42 +03:30
function setStatusFilter ( next : string ) {
const value : StatusFilter =
next === 'open' || next === 'done' || next === 'canceled' ? next : 'all' ;
patchFacetQuery ({ status : value === 'all' ? undefined : value });
2026-10-03 12:00:46 +03:30
}
function setAssigneeFilter ( next : string ) {
patchFacetQuery ({
assigneeId : next || undefined ,
desk : next && showDeskTabs . value ? 'team' : route . query . desk === 'team' ? 'team' : undefined ,
});
}
2026-11-01 21:20:42 +03:30
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 });
}
2026-10-03 12:00:46 +03:30
function clearFacets () {
patchFacetQuery ({
type : undefined ,
2026-11-01 21:20:42 +03:30
typeDefId : undefined ,
2026-10-03 12:00:46 +03:30
status : undefined ,
assigneeId : undefined ,
2026-11-01 21:20:42 +03:30
title : undefined ,
customer : undefined ,
due : undefined ,
2026-10-03 12:00:46 +03:30
});
2026-09-02 21:29:00 +03:30
}
2026-11-01 21:20:42 +03:30
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 ;
}
2026-08-29 21:06:30 +03:30
async function complete ( id : string ) {
2026-09-02 21:29:00 +03:30
menuId . value = null ;
2026-10-17 12:47:07 +03:30
boardMenuId . value = null ;
2026-10-17 17:28:57 +03:30
boardMenuActivity . value = null ;
2026-08-29 21:06:30 +03:30
try {
await api . patch ( `/activities/ ${ id } /complete` );
2026-10-07 11:18:53 +03:30
await refreshAll ();
2026-08-29 21:06:30 +03:30
} catch {
error . value = 'ثبت انجامشدن ناموفق بود.' ;
}
}
2026-09-02 21:29:00 +03:30
function closePopovers () {
filterOpen . value = false ;
2026-11-01 21:20:42 +03:30
openCol . value = null ;
2026-09-02 21:29:00 +03:30
menuId . value = null ;
}
2026-10-17 17:28:57 +03:30
function canPromotePrior ( item : ActivityDto ) {
const pa = auth . user ? . priorArchive ;
if ( ! pa || pa . lens !== 'archive' || ! pa . cutoffAt ) return false ;
if ( item . promotedFromPriorAt ) return false ;
return new Date ( item . createdAt ). getTime () < new Date ( pa . cutoffAt ). getTime ();
}
async function promotePriorActivity ( id : string ) {
menuId . value = null ;
boardMenuId . value = null ;
boardMenuActivity . value = null ;
error . value = '' ;
try {
await api . post ( `/activities/ ${ id } /promote-from-prior` );
kanbanReload . value += 1 ;
await refreshAll ();
} catch {
error . value = 'انتقال فعالیت به کار جاری ناموفق بود.' ;
}
}
2026-10-17 12:47:07 +03:30
const deleteConfirmCopy = computed (() => {
const n = deleteActivityIds . value . length ;
if ( n <= 0 ) return '' ;
if ( n === 1 ) {
const title = deleteSingleTitle . value . trim ();
return title
? `فعالیت « ${ title } » حذف شود؟ این کار قابل بازگشت نیست.`
: 'این فعالیت حذف شود؟ این کار قابل بازگشت نیست.' ;
}
return ` ${ toFaDigits ( n ) } فعالیت حذف شود؟ این کار قابل بازگشت نیست.` ;
});
function activityTitle ( id : string ) {
return items . value . find (( item ) => item . id === id ) ? . title ?? '' ;
}
function startSelect ( id : string ) {
menuId . value = null ;
boardMenuId . value = null ;
2026-10-17 17:28:57 +03:30
boardMenuActivity . value = null ;
2026-10-17 12:47:07 +03:30
selectMode . value = true ;
selectedIds . value = new Set ([ id ]);
}
function toggleSelect ( id : string ) {
const next = new Set ( selectedIds . value );
if ( next . has ( id )) next . delete ( id );
else next . add ( id );
selectedIds . value = next ;
if ( ! next . size ) selectMode . value = false ;
}
function clearSelect () {
selectMode . value = false ;
selectedIds . value = new Set ();
}
function askDeleteActivity ( id : string , title ?: string ) {
menuId . value = null ;
boardMenuId . value = null ;
2026-10-17 17:28:57 +03:30
boardMenuActivity . value = null ;
2026-10-17 12:47:07 +03:30
deleteSingleTitle . value = title ? . trim () || activityTitle ( id );
deleteActivityIds . value = [ id ];
}
function askBulkDelete () {
if ( ! selectedIds . value . size ) return ;
menuId . value = null ;
boardMenuId . value = null ;
2026-10-17 17:28:57 +03:30
boardMenuActivity . value = null ;
2026-10-17 12:47:07 +03:30
deleteSingleTitle . value = '' ;
deleteActivityIds . value = [... selectedIds . value ];
}
async function confirmDeleteActivities () {
const ids = deleteActivityIds . value ;
if ( ! ids . length || activityDeleting . value ) return ;
activityDeleting . value = true ;
error . value = '' ;
try {
const results = await Promise . allSettled ( ids . map (( id ) => api . delete ( `/activities/ ${ id } ` )));
const failed = results . filter (( r ) => r . status === 'rejected' ). length ;
deleteActivityIds . value = [];
clearSelect ();
kanbanReload . value += 1 ;
await refreshAll ();
if ( failed ) {
error . value =
failed === ids . length
? 'حذف فعالیتها ناموفق بود.'
: ` ${ toFaDigits ( failed ) } مورد حذف نشد.` ;
}
} finally {
activityDeleting . value = false ;
}
}
2026-10-17 17:28:57 +03:30
function onBoardMenu ( activity : ActivityDto ) {
boardMenuId . value = boardMenuId . value === activity . id ? null : activity . id ;
boardMenuActivity . value = boardMenuId . value ? activity : null ;
2026-10-17 13:26:11 +03:30
boardMenuStyle . value = null ;
if ( ! boardMenuId . value ) return ;
void nextTick (() => {
2026-10-17 17:28:57 +03:30
boardMenuStyle . value = cardMenuStyle ( activity . id );
2026-10-17 13:26:11 +03:30
});
2026-10-17 12:47:07 +03:30
}
const unbindBoardMenuDismiss = bindMenuDismiss (
() => boardMenuId . value != null ,
() => {
boardMenuId . value = null ;
2026-10-17 17:28:57 +03:30
boardMenuActivity . value = null ;
2026-10-17 12:47:07 +03:30
},
);
2026-09-15 17:33:05 +03:30
async function exportExcel () {
busy . value = true ;
2026-10-03 12:29:11 +03:30
pageBusy . value = true ;
busyMessage . value = 'در حال تهیه خروجی اکسل…' ;
2026-09-15 17:33:05 +03:30
importMsg . value = '' ;
error . value = '' ;
try {
const { data } = await api . get < Blob >( '/activities/export' , { responseType : 'blob' });
const url = URL . createObjectURL ( data );
const a = document . createElement ( 'a' );
a . href = url ;
a . download = ` ${ brand . filePrefix } -activities.xlsx` ;
a . click ();
URL . revokeObjectURL ( url );
} catch {
error . value = 'خروجی اکسل ناموفق بود.' ;
} finally {
busy . value = false ;
2026-10-03 12:29:11 +03:30
pageBusy . value = false ;
2026-09-15 17:33:05 +03:30
}
}
2026-09-19 15:30:31 +03:30
function onImportStart () {
2026-09-15 17:33:05 +03:30
busy . value = true ;
2026-10-03 12:29:11 +03:30
pageBusy . value = false ;
2026-09-15 17:33:05 +03:30
importMsg . value = '' ;
error . value = '' ;
2026-09-19 15:30:31 +03:30
}
async function onImportDone ( data : ImportContactsResult ) {
importMsg . value = `وارد شد: ${ data . created } · تکراری رد شد: ${ data . skipped }${
data . errors . length ? ` · خطا: ${ data . errors . length } ` : ''
} ` ;
if ( data . errors . length ) {
error . value = data . errors
. slice ( 0 , 3 )
. map (( e ) => `ردیف ${ e . row } : ${ e . message } ` )
. join ( ' | ' );
2026-09-15 17:33:05 +03:30
}
2026-10-07 11:18:53 +03:30
kanbanReload . value += 1 ;
await refreshAll ();
2026-09-19 15:30:31 +03:30
}
function onImportFail ( message : string ) {
error . value = message ;
}
function onImportSettled () {
busy . value = false ;
2026-10-03 12:29:11 +03:30
pageBusy . value = false ;
2026-09-15 17:33:05 +03:30
}
2026-08-29 21:06:30 +03:30
watch (
2026-10-03 12:00:46 +03:30
() => [
route . query . filter ,
route . query . q ,
route . query . page ,
route . query . desk ,
route . query . type ,
2026-10-07 12:29:23 +03:30
route . query . typeDefId ,
2026-10-03 12:00:46 +03:30
route . query . status ,
route . query . assigneeId ,
2026-11-01 21:20:42 +03:30
route . query . title ,
route . query . customer ,
route . query . due ,
2026-10-07 11:18:53 +03:30
route . query . stage ,
route . query . view ,
2026-10-03 12:00:46 +03:30
],
2026-08-29 21:06:30 +03:30
() => {
q . value = typeof route . query . q === 'string' ? route . query . q : '' ;
void load ();
2026-09-28 12:28:14 +03:30
void loadCounts ();
2026-08-29 21:06:30 +03:30
},
);
2026-10-20 17:23:27 +03:30
/** گروه عوض شد → ستونهای همان گروه، بعد لیست و شمارشها */
watch ( board , ( next , prev ) => {
if ( ! prev || next === prev ) return ;
clearSelect ();
void loadStages (). then (() => {
kanbanReload . value += 1 ;
void load ();
void loadCounts ();
});
});
2026-10-07 11:18:53 +03:30
/** لینکهای قدیمی ?filter=done → ستون انجامشده */
function migrateLegacyDoneFilter () {
if ( route . query . filter !== 'done' ) return false ;
const doneStage = stages . value . find (( s ) => s . kind === 'done' );
void router . replace ({
name : 'activities' ,
query : {
... route . query ,
filter : 'all' ,
stage : doneStage ? . id ,
},
});
return true ;
}
2026-10-31 15:05:05 +03:30
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 });
}
2026-09-23 16:32:41 +03:30
let mobileQuery : MediaQueryList | null = null ;
function syncMobile () {
isMobile . value = mobileQuery ? . matches ?? false ;
2026-10-31 15:05:05 +03:30
void nextTick ( revealActiveTimeChip );
2026-09-23 16:32:41 +03:30
}
2026-09-02 21:29:00 +03:30
onMounted (() => {
2026-09-23 16:32:41 +03:30
mobileQuery = window . matchMedia ( '(max-width: 899px)' );
syncMobile ();
mobileQuery . addEventListener ( 'change' , syncMobile );
2026-10-03 12:00:46 +03:30
void loadUsers ();
2026-10-07 12:29:23 +03:30
void activityTypes . load ( true );
2026-10-20 17:23:27 +03:30
void loadBoards ()
. then (() => loadStages ())
. then (() => {
if ( migrateLegacyDoneFilter ()) return ;
void load ();
void loadCounts ();
});
2026-09-02 21:29:00 +03:30
});
2026-09-23 16:32:41 +03:30
2026-10-17 12:47:07 +03:30
watch ( viewMode , () => {
clearSelect ();
boardMenuId . value = null ;
2026-10-17 17:28:57 +03:30
boardMenuActivity . value = null ;
2026-10-17 12:47:07 +03:30
menuId . value = null ;
});
2026-10-31 15:05:05 +03:30
watch ( filter , () => {
void nextTick ( revealActiveTimeChip );
});
watch ( counts , () => {
void nextTick ( revealActiveTimeChip );
});
2026-09-23 16:32:41 +03:30
onUnmounted (() => {
mobileQuery ? . removeEventListener ( 'change' , syncMobile );
2026-10-17 12:47:07 +03:30
unbindBoardMenuDismiss ();
2026-09-23 16:32:41 +03:30
});
2026-08-29 21:06:30 +03:30
</ script >
< template >
2026-09-02 21:29:00 +03:30
< section class = "page" @click ="closePopovers" >
2026-10-03 12:29:11 +03:30
< UiBusyOverlay :open = "pageBusy" :message = "busyMessage" />
2026-10-31 13:57:01 +03:30
< 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" >
2026-10-31 15:05:05 +03:30
< div class = "desk-actions-desktop" >
2026-10-31 13:57:01 +03:30
< UiButton
v-if = "canManageStages"
2026-10-31 15:05:05 +03:30
class = "manage-btn"
2026-10-31 13:57:01 +03:30
variant = "ghost"
size = "sm"
type = "button"
2026-10-31 15:05:05 +03:30
title = "انواع فعالیت و فیلدها"
2026-10-31 13:57:01 +03:30
@click ="typesManagerOpen = true"
>
< UiIcon name = "sliders" :size = "14" />
2026-10-31 15:05:05 +03:30
< span class = "manage-label" > انواع فعالیت و فیلدها </ span >
2026-09-12 01:33:07 +03:30
</ UiButton >
2026-10-31 13:57:01 +03:30
< UiButton
v-if = "canManageStages"
2026-10-31 15:05:05 +03:30
class = "manage-btn"
2026-10-31 13:57:01 +03:30
variant = "ghost"
size = "sm"
type = "button"
2026-10-31 15:05:05 +03:30
title = "مدیریت ستونها"
2026-10-31 13:57:01 +03:30
@click ="stagesManagerOpen = true"
>
< UiIcon name = "settings" :size = "14" />
2026-10-31 15:05:05 +03:30
< span class = "manage-label" > مدیریت ستون ها </ span >
2026-10-31 13:57:01 +03:30
</ UiButton >
2026-10-31 15:05:05 +03:30
< 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" />
2026-10-31 13:57:01 +03:30
< 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 }" >
2026-10-31 15:05:05 +03:30
< 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" />
2026-10-31 13:57:01 +03:30
< 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 >
2026-10-31 15:05:05 +03:30
</ 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 >
2026-10-31 13:57:01 +03:30
</ div >
2026-10-07 11:18:53 +03:30
</ div >
2026-10-31 13:57:01 +03:30
2026-10-31 15:05:05 +03:30
< div class = "desk-break" aria -hidden =" true " ></ div >
< div class = "desk-bar" : class = "{ 'has-tabs': showDeskTabs }" >
2026-10-31 13:57:01 +03:30
< 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"
2026-10-31 15:05:05 +03:30
: placeholder = "isMobile ? 'جستجو…' : 'جستجو در عنوان، مشتری، کارشناس، نوع فعالیت...'"
2026-10-31 13:57:01 +03:30
/>
</ form >
< div class = "filter-wrap" @click.stop >
< button
class = "btn ghost filter-btn"
type = "button"
: class = "{ on: activeFacetCount > 0 }"
2026-10-31 15:05:05 +03:30
:title = "filterBtnLabel"
:aria-label = "filterBtnLabel"
2026-11-01 21:20:42 +03:30
@click ="toggleFilter"
2026-10-31 13:57:01 +03:30
>
< UiIcon name = "filter" :size = "15" />
2026-10-31 15:05:05 +03:30
< span class = "filter-label" >{{ filterBtnLabel }}</ span >
2026-10-31 13:57:01 +03:30
< UiIcon name = "chevron" :size = "13" class = "chev" />
</ button >
< div v-if = "filterOpen" class="popover filter-popover" >
2026-11-01 21:20:42 +03:30
< ActivityDeskFilters
field = "all"
:title = "titleFilter"
:customer = "customerFilter"
:due = "dueFilter"
:type-key = "typeFilter"
:status-key = "statusFilter"
:assignee-id = "assigneeFilter"
:show-assignee = "showDeskTabs"
:users = "users"
:types = "TYPE_FILTERS"
:statuses = "STATUS_FILTERS"
@title ="setTitleFilter"
@customer ="setCustomerFilter"
@due ="setDueFilter"
@type ="setTypeFilter"
@status ="setStatusFilter"
@assignee ="setAssigneeFilter"
@clear ="clearFacets"
/>
2026-10-31 13:57:01 +03:30
</ div >
</ div >
</ div >
2026-10-31 15:05:05 +03:30
< div ref = "timeChipsEl" class = "time-chips" role = "tablist" aria -label =" فیلتر زمانی " >
2026-10-31 13:57:01 +03:30
< 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 >
2026-10-20 17:59:34 +03:30
< 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"
2026-10-20 17:23:27 +03:30
type = "button"
2026-10-20 17:59:34 +03:30
: class = "{ on: b.id === board }"
@click ="setBoard(b.id)"
2026-10-20 17:23:27 +03:30
>
2026-10-20 17:59:34 +03:30
{{ 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 >
2026-10-07 11:18:53 +03:30
</ div >
2026-10-31 13:57:01 +03:30
< div v-if = "viewMode === 'tabs'" class="tabs" role="tablist" aria-label="ستونهای فعالیت" >
2026-09-28 12:28:14 +03:30
< button
type = "button"
role = "tab"
2026-10-07 11:18:53 +03:30
: class = "{ on: stage === 'all' }"
: aria -selected =" stage = == ' all '"
@click ="setStage('all')"
2026-09-28 12:28:14 +03:30
>
2026-10-07 11:18:53 +03:30
< 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 >
2026-09-28 12:28:14 +03:30
</ button >
</ div >
2026-08-29 21:06:30 +03:30
</ div >
< p v-if = "error" class="error" >{{ error }}</ p >
2026-09-15 17:33:05 +03:30
< p v-if = "importMsg" class="ok" >{{ importMsg }}</ p >
2026-10-07 11:18:53 +03:30
2026-10-17 12:47:07 +03:30
< 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 >
2026-10-07 11:18:53 +03:30
< ActivitiesKanban
v-if = "viewMode === 'board'"
:stages = "stages"
:params = "sharedParams"
:can-manage = "canManageStages"
:reload-key = "kanbanReload"
2026-10-17 12:47:07 +03:30
:select-mode = "selectMode"
:selected-ids = "selectedIds"
2026-10-07 11:18:53 +03:30
@changed ="loadCounts"
@ stages -changed =" onStagesChanged "
@add ="openAddInStage"
@error ="(msg: string) => (error = msg)"
2026-10-17 12:47:07 +03:30
@ menu -deal =" onBoardMenu "
@ toggle -select =" toggleSelect "
@ bulk -delete =" askBulkDelete "
2026-10-07 11:18:53 +03:30
/>
< p v-else-if = "loading" class="muted" > در حال بارگذاری … </ p >
2026-08-29 21:06:30 +03:30
2026-09-02 21:29:00 +03:30
< 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 >
2026-09-10 01:22:24 +03:30
< UiButton block type = "button" @click ="activityModal = {}" >
2026-09-02 21:29:00 +03:30
{{ trulyEmpty ? ' اولین فعالیتت رو ثبت کن ' : ' فعالیت جدید ' }}
2026-09-10 01:22:24 +03:30
</ UiButton >
2026-09-02 21:29:00 +03:30
< button class = "btn ghost wide" type = "button" @click ="ideasOpen = !ideasOpen" >
< UiIcon name = "lightbulb" :size = "18" />
ایده هایی برای شروع
</ button >
< div v-if = "ideasOpen" class="idea-list" >
2026-09-10 01:22:24 +03:30
< button type = "button" @click ="activityModal = { type: 'call', title: 'تماس پیگیری' }" >
2026-09-02 21:29:00 +03:30
تماس پیگیری
2026-09-10 01:22:24 +03:30
</ button >
< button type = "button" @click ="activityModal = { type: 'meeting', title: 'جلسه دمو' }" >
2026-09-02 21:29:00 +03:30
جلسه دمو
2026-09-10 01:22:24 +03:30
</ button >
< button type = "button" @click ="activityModal = { type: 'task', title: 'وظیفه یادآوری' }" >
2026-09-02 21:29:00 +03:30
وظیفه یادآوری
2026-09-10 01:22:24 +03:30
</ button >
2026-09-02 21:29:00 +03:30
</ div >
2026-08-29 21:06:30 +03:30
</ div >
2026-09-02 21:29:00 +03:30
< template v-else >
< div class = "list-head" >
2026-10-05 20:00:21 +03:30
< strong >{{ filterLabel }} — {{ toFaDigits ( total . toLocaleString ( 'en-US' )) }} فعالیت </ strong >
2026-10-06 13:35:13 +03:30
< 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 >
2026-09-02 21:29:00 +03:30
</ div >
< div class = "table desktop-only" role = "table" >
2026-10-06 13:35:13 +03:30
< div class = "thead" role = "row" :style = "actGridStyle" >
2026-10-05 20:00:21 +03:30
< span class = "row-num" > # </ span >
2026-10-17 12:47:07 +03:30
< span v-if = "selectMode" class="tick-col" />
2026-10-06 13:35:13 +03:30
< template v-for = "col in ACT_COLUMNS" :key="col.key" >
2026-11-01 21:20:42 +03:30
< 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"
:status-key = "statusFilter"
:assignee-id = "assigneeFilter"
:show-assignee = "showDeskTabs"
:users = "users"
:types = "TYPE_FILTERS"
:statuses = "STATUS_FILTERS"
@title ="setTitleFilter"
@customer ="setCustomerFilter"
@due ="setDueFilter"
@type ="setTypeFilter"
@status ="setStatusFilter"
@assignee ="setAssigneeFilter"
@done ="onFacetDone"
/>
</ div >
</ div >
2026-10-06 13:35:13 +03:30
</ template >
2026-09-02 21:29:00 +03:30
< span />
</ div >
2026-09-09 13:12:13 +03:30
< article
2026-10-05 20:00:21 +03:30
v-for = "(item, idx) in items"
2026-09-09 13:12:13 +03:30
:key = "item.id"
class = "trow"
2026-10-17 12:47:07 +03:30
: class = "{ selected: selectedIds.has(item.id), 'select-mode': selectMode }"
2026-09-09 13:12:13 +03:30
role = "row"
2026-10-06 13:35:13 +03:30
:style = "actGridStyle"
2026-10-17 12:47:07 +03:30
:data-card-id = "item.id"
@click ="selectMode && toggleSelect(item.id)"
2026-09-09 13:12:13 +03:30
>
2026-10-05 20:00:21 +03:30
< span class = "row-num" >{{ toFaDigits (( page - 1 ) * pageSize + idx + 1 ) }}</ span >
2026-10-17 12:47:07 +03:30
< 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 >
2026-10-06 13:35:13 +03:30
< div v-if = "colOn('title')" class="title-cell" >
2026-09-02 21:29:00 +03:30
< UiIcon name = "star" :size = "16" class = "star" />
< div >
2026-10-29 09:22:23 +03:30
< RouterLink class = "title" :to = "activityRoute(item.id)" >
2026-09-02 21:29:00 +03:30
{{ item . title }}
</ RouterLink >
< p v-if = "subtitle(item)" class="sub" >{{ subtitle ( item ) }}</ p >
2026-10-07 13:17:29 +03:30
< 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 >
2026-09-02 21:29:00 +03:30
</ div >
</ div >
2026-10-06 13:35:13 +03:30
< div v-if = "colOn('who')" class="who" >
2026-10-03 13:16:13 +03:30
< strong >{{
item . assignees ? . length
? item . assignees . map (( a ) => a . fullName ). join ( '، ' )
: item . assignee ? . fullName || '—'
}}</ strong >
2026-10-07 12:29:23 +03:30
</ 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 }}
2026-09-02 21:29:00 +03:30
</ span >
</ div >
2026-10-07 12:29:23 +03:30
< div v-if = "colOn('when')" class="when" :class="{ late: uiStatus(item) === 'overdue' }" >
< strong >{{ clockText ( item ) }}</ strong >
< span >{{ dayText ( item ) }}</ span >
</ div >
2026-10-07 11:18:53 +03:30
< div v-if = "colOn('status')" class="status-cell" >
2026-09-02 21:29:00 +03:30
< span class = "status" :class = "uiStatus(item)" >
< UiIcon :name = "statusIcon[uiStatus(item)]" :size = "13" />
{{ statusLabel [ uiStatus ( item )] }}
</ span >
2026-10-07 11:18:53 +03:30
< 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 >
2026-09-02 21:29:00 +03:30
</ div >
2026-09-23 16:32:41 +03:30
< div v-if = "!isMobile" class="row-more" @click.stop >
2026-10-17 12:47:07 +03:30
< 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 >
2026-09-22 11:02:08 +03:30
< UiDropdownMenu
2026-10-17 12:47:07 +03:30
v-else
2026-09-22 11:02:08 +03:30
: open = "menuId === item.id"
align = "end"
@ update : open = "(v) => (menuId = v ? item.id : null)"
2026-09-02 21:29:00 +03:30
>
2026-09-22 11:02:08 +03:30
< template # trigger = "{ toggle }" >
< button
2026-10-17 12:47:07 +03:30
class = "icon-more menu"
2026-09-22 11:02:08 +03:30
type = "button"
aria -label =" عملیات "
@click.stop ="toggle"
>
< UiIcon name = "dots-v" :size = "16" />
</ button >
</ template >
2026-10-17 12:47:07 +03:30
< button type = "button" @click ="startSelect(item.id)" > انتخاب </ button >
2026-10-29 09:22:23 +03:30
< RouterLink :to = "activityRoute(item.id)" >
2026-09-02 21:29:00 +03:30
مشاهده
</ RouterLink >
2026-10-29 09:22:23 +03:30
< RouterLink : to = "activityRoute(item.id, true)" >
2026-09-02 21:29:00 +03:30
ویرایش
</ RouterLink >
2026-10-17 17:28:57 +03:30
< button v-if = "canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)" >
انتقال به کار جاری
</ button >
2026-09-02 21:29:00 +03:30
< button v-if = "item.status === 'open'" type="button" @click="complete(item.id)" >
انجام شد
</ button >
2026-10-17 12:47:07 +03:30
< button type = "button" class = "danger" @click ="askDeleteActivity(item.id, item.title)" >
حذف
</ button >
2026-09-22 11:02:08 +03:30
</ UiDropdownMenu >
2026-09-02 21:29:00 +03:30
</ div >
</ article >
</ div >
< div class = "cards mobile-only" >
2026-10-17 12:47:07 +03:30
< 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)"
>
2026-09-02 21:29:00 +03:30
< div class = "card-top" >
2026-10-17 12:47:07 +03:30
< 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 >
2026-09-02 21:29:00 +03:30
< div class = "card-main" >
< time :class = "item.type" >{{ clockText ( item ) }}</ time >
2026-10-17 12:47:07 +03:30
< RouterLink
class = "title"
2026-10-29 09:22:23 +03:30
:to = "activityRoute(item.id)"
2026-10-17 12:47:07 +03:30
@click ="selectMode && $event.preventDefault()"
>
2026-09-02 21:29:00 +03:30
{{ 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" >
2026-10-17 12:47:07 +03:30
< 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 >
2026-09-22 11:02:08 +03:30
< UiDropdownMenu
2026-10-17 12:47:07 +03:30
v-else-if = "isMobile"
2026-09-22 11:02:08 +03:30
: open = "menuId === item.id"
align = "end"
@ update : open = "(v) => (menuId = v ? item.id : null)"
2026-09-02 21:29:00 +03:30
>
2026-09-22 11:02:08 +03:30
< template # trigger = "{ toggle }" >
< button
2026-10-17 12:47:07 +03:30
class = "icon-more menu"
2026-09-22 11:02:08 +03:30
type = "button"
aria -label =" عملیات "
@click.stop ="toggle"
>
< UiIcon name = "dots-v" :size = "16" />
</ button >
</ template >
2026-10-17 12:47:07 +03:30
< button type = "button" @click ="startSelect(item.id)" > انتخاب </ button >
2026-10-29 09:22:23 +03:30
< RouterLink :to = "activityRoute(item.id)" >
2026-09-02 21:29:00 +03:30
مشاهده
</ RouterLink >
2026-10-29 09:22:23 +03:30
< RouterLink : to = "activityRoute(item.id, true)" >
2026-10-17 17:28:57 +03:30
ویرایش
</ RouterLink >
< button v-if = "canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)" >
انتقال به کار جاری
</ button >
2026-09-02 21:29:00 +03:30
< button v-if = "item.status === 'open'" type="button" @click="complete(item.id)" >
انجام شد
</ button >
2026-10-17 12:47:07 +03:30
< button type = "button" class = "danger" @click ="askDeleteActivity(item.id, item.title)" >
حذف
</ button >
2026-09-22 11:02:08 +03:30
</ UiDropdownMenu >
2026-10-07 12:29:23 +03:30
< 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 }}
2026-09-22 11:02:08 +03:30
</ span >
2026-09-02 21:29:00 +03:30
</ div >
</ div >
< footer class = "card-foot" >
< span >
< UiIcon name = "calendar" :size = "14" />
{{ dateText ( item ) }}
</ span >
2026-09-23 16:32:41 +03:30
< span class = "status" : class = "item.status === 'done' ? 'done' : item.status === 'canceled' ? 'canceled' : 'planned'" >
{{ cardStatusText ( item ) }}
2026-09-02 21:29:00 +03:30
</ span >
</ footer >
</ article >
</ div >
2026-09-12 01:33:07 +03:30
< UiPagination
v-if = "total"
:page = "page"
:total-pages = "pageCount"
:total-items = "total"
2026-10-05 20:00:21 +03:30
:page-size = "pageSize"
:page-size-options = "PAGE_SIZE_OPTIONS"
2026-09-12 01:33:07 +03:30
@ update :page = "setPage"
2026-10-05 20:00:21 +03:30
@ update :page-size = "onPageSize"
2026-09-12 01:33:07 +03:30
/>
2026-09-02 21:29:00 +03:30
</ template >
2026-09-10 01:22:24 +03:30
2026-10-17 13:26:11 +03:30
< Teleport to = "body" >
< div
v-if = "boardMenuId"
class = "menu-pop"
role = "menu"
: style = "boardMenuStyle ?? undefined"
@click.stop
2026-10-17 12:47:07 +03:30
>
2026-10-17 13:26:11 +03:30
< button type = "button" role = "menuitem" @click ="startSelect(boardMenuId)" > انتخاب </ button >
< RouterLink
role = "menuitem"
2026-10-29 09:22:23 +03:30
:to = "activityRoute(boardMenuId)"
2026-10-17 17:28:57 +03:30
@click ="boardMenuId = null; boardMenuActivity = null"
2026-10-17 13:26:11 +03:30
>
مشاهده
</ RouterLink >
< RouterLink
role = "menuitem"
2026-10-29 09:22:23 +03:30
: to = "activityRoute(boardMenuId, true)"
2026-10-17 17:28:57 +03:30
@click ="boardMenuId = null; boardMenuActivity = null"
2026-10-17 13:26:11 +03:30
>
ویرایش
</ RouterLink >
2026-10-17 17:28:57 +03:30
< button
v-if = "boardMenuActivity && canPromotePrior(boardMenuActivity)"
type = "button"
role = "menuitem"
@click ="promotePriorActivity(boardMenuActivity.id)"
>
انتقال به کار جاری
</ button >
2026-10-17 13:26:11 +03:30
< button
type = "button"
role = "menuitem"
class = "danger"
@click ="askDeleteActivity(boardMenuId)"
>
حذف
</ button >
2026-10-17 17:28:57 +03:30
< button type = "button" role = "menuitem" @click ="boardMenuId = null; boardMenuActivity = null" > بستن </ button >
2026-10-17 13:26:11 +03:30
</ div >
</ Teleport >
2026-10-17 12:47:07 +03:30
2026-09-10 01:22:24 +03:30
< UiModal
:open = "Boolean(activityModal)"
title = "فعالیت جدید"
@close ="activityModal = null"
>
< ActivityFormView
v-if = "activityModal"
embedded
:initial-type = "activityModal.type"
:initial-title = "activityModal.title"
2026-10-07 11:18:53 +03:30
:initial-stage-id = "activityModal.stageId"
2026-10-20 17:23:27 +03:30
: initial -board-id =" board || undefined "
2026-10-07 11:18:53 +03:30
@saved ="onActivitySaved"
2026-09-10 01:22:24 +03:30
@cancel ="activityModal = null"
/>
</ UiModal >
2026-10-07 11:18:53 +03:30
2026-10-17 12:47:07 +03:30
< 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 >
2026-10-07 11:18:53 +03:30
< ActivityStagesManager
v-if = "canManageStages"
:open = "stagesManagerOpen"
:stages = "stages"
2026-10-20 17:23:27 +03:30
: board -id =" board || undefined "
: board -name =" boards.length > 1 ? currentBoard ? . name : undefined "
2026-10-07 11:18:53 +03:30
@close=" stagesManagerOpen = false "
@changed=" onStagesChanged (); kanbanReload += 1 "
/>
2026-10-20 17:23:27 +03:30
<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>
2026-10-28 20:24:40 +03:30
<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>
2026-10-20 17:23:27 +03:30
<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>
2026-10-07 12:29:23 +03:30
<ActivityTypesManager
v-if=" canManageStages "
:open=" typesManagerOpen "
@close=" typesManagerOpen = false "
@changed=" onTypesChanged "
/>
2026-08-29 21:06:30 +03:30
</ section >
</ template >
< style scoped >
. page {
2026-10-19 11:18:19 +03:30
display : flex ;
flex - direction : column ;
2026-10-31 13:57:01 +03:30
gap : 0.75 rem ;
2026-08-29 21:06:30 +03:30
min - width : 0 ;
2026-10-19 11:18:19 +03:30
min - height : 0 ;
height : 100 % ;
2026-08-29 21:06:30 +03:30
}
2026-10-31 13:57:01 +03:30
. desk - chrome {
2026-09-02 21:29:00 +03:30
display : flex ;
2026-10-31 13:57:01 +03:30
flex - direction : column ;
gap : 8 px ;
flex - shrink : 0 ;
min - width : 0 ;
}
. desk - top {
display : flex ;
align - items : center ;
justify - content : flex - start ;
gap : 8 px 10 px ;
flex - wrap : nowrap ;
min - width : 0 ;
}
. desk - title {
min - width : 0 ;
flex : 0 1 auto ;
}
. desk - title h1 {
margin : 0 ;
font - size : 1.15 rem ;
font - weight : 800 ;
line - height : 1.25 ;
}
. desk - title . lead {
display : flex ;
align - items : center ;
flex - wrap : nowrap ;
2026-09-02 21:29:00 +03:30
gap : 0.35 rem ;
2026-10-31 13:57:01 +03:30
margin : 0 ;
color : # 64748 b ;
font - size : 0.75 rem ;
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 : 6 px ;
margin - inline - start : auto ;
flex : 0 1 auto ;
min - width : 0 ;
}
. desk - actions : deep (. icon - only ) {
width : 30 px ;
min - width : 30 px ;
padding : 0 ;
}
. view - toggle {
display : inline - flex ;
align - items : center ;
height : 30 px ;
padding : 2 px ;
gap : 2 px ;
background : # e8eef1 ;
border - radius : var ( -- radius );
}
. view - toggle . seg {
display : inline - flex ;
align - items : center ;
justify - content : center ;
width : 26 px ;
height : 26 px ;
padding : 0 ;
border : 0 ;
border - radius : calc ( var ( -- radius ) - 2 px );
background : transparent ;
color : # 7 b8794 ;
cursor : pointer ;
}
. view - toggle . seg . on {
background : # fff ;
color : var ( -- accent - deep );
box - shadow : 0 1 px 3 px rgba ( 15 , 23 , 42 , 0.06 );
}
. desk - bar {
display : flex ;
align - items : center ;
justify - content : flex - start ;
flex - wrap : wrap ;
gap : 6 px ;
min - width : 0 ;
}
. desk - bar . tools {
flex : 0 1 340 px ;
min - width : 200 px ;
gap : 6 px ;
}
. desk - bar . search {
flex : 1 1 160 px ;
max - width : 280 px ;
height : 30 px ;
min - height : 30 px ;
padding : 0 0.55 rem ;
font - size : 0.78 rem ;
}
. desk - bar . filter - btn {
height : 30 px ;
min - height : 30 px ;
padding : 0 0.55 rem ;
font - size : 0.78 rem ;
}
. crumbs {
display : inline - flex ;
gap : 0.3 rem ;
2026-09-02 21:29:00 +03:30
align - items : center ;
color : # 94 a3b8 ;
2026-10-31 13:57:01 +03:30
font - size : inherit ;
2026-09-02 21:29:00 +03:30
}
. crumbs a {
color : inherit ;
}
2026-08-29 21:06:30 +03:30
. page - head {
display : flex ;
justify - content : space - between ;
gap : 1 rem ;
align - items : flex - start ;
}
. eyebrow {
margin : 0 ;
2026-09-02 21:29:00 +03:30
color : # 0 f766e ;
2026-08-29 21:06:30 +03:30
font - weight : 700 ;
2026-09-02 21:29:00 +03:30
font - size : 0.8 rem ;
2026-08-29 21:06:30 +03:30
}
h1 {
2026-09-02 21:29:00 +03:30
margin : 0.15 rem 0 0.3 rem ;
2026-08-29 21:06:30 +03:30
font - size : 1.85 rem ;
}
. lead ,
. muted ,
2026-09-02 21:29:00 +03:30
. sub ,
. error {
2026-08-29 21:06:30 +03:30
margin : 0 ;
}
2026-09-02 21:29:00 +03:30
. lead ,
. muted ,
. sub {
color : # 64748 b ;
}
. error {
color : # b91c1c ;
}
2026-09-15 17:33:05 +03:30
. ok {
color : # 0 f766e ;
font - weight : 600 ;
}
2026-10-10 11:58:45 +03:30
. btn - short {
2026-09-15 17:33:05 +03:30
display : none ;
}
2026-09-02 21:29:00 +03:30
. head - actions ,
. tools {
2026-08-29 21:06:30 +03:30
display : flex ;
2026-09-02 21:29:00 +03:30
gap : 0.55 rem ;
2026-08-29 21:06:30 +03:30
align - items : center ;
2026-09-15 17:33:05 +03:30
flex - wrap : wrap ;
2026-09-02 21:29:00 +03:30
}
. btn {
display : inline - flex ;
align - items : center ;
justify - content : center ;
gap : 0.35 rem ;
2026-09-12 01:33:07 +03:30
height : var ( -- control - h );
2026-09-10 23:45:11 +03:30
min - height : var ( -- control - h );
2026-09-12 01:33:07 +03:30
padding : 0 0.85 rem ;
2026-09-10 23:45:11 +03:30
border - radius : var ( -- radius );
2026-09-12 01:33:07 +03:30
font - weight : 600 ;
font - size : var ( -- fs - sm );
2026-09-02 21:29:00 +03:30
border : 1 px solid transparent ;
cursor : pointer ;
font : inherit ;
text - decoration : none ;
2026-09-12 01:33:07 +03:30
box - sizing : border - box ;
transition : background var ( -- transition - fast ), border - color var ( -- transition - fast );
2026-09-02 21:29:00 +03:30
}
. btn . primary {
2026-09-12 01:33:07 +03:30
background : var ( -- accent );
2026-09-02 21:29:00 +03:30
color : # fff ;
}
. btn . ghost {
background : # fff ;
2026-09-12 01:33:07 +03:30
color : var ( -- ink - secondary );
border - color : var ( -- line );
}
. btn . ghost : hover {
background : var ( -- surface - subtle );
border - color : var ( -- line - strong );
2026-09-02 21:29:00 +03:30
}
. btn . wide {
width : min ( 100 % , 320 px );
2026-08-29 21:06:30 +03:30
}
2026-09-28 12:28:14 +03:30
. desk - tabs {
display : inline - flex ;
2026-10-31 13:57:01 +03:30
flex : 0 0 auto ;
gap : 0.15 rem ;
padding : 2 px ;
border - radius : 8 px ;
2026-09-28 12:28:14 +03:30
background : # f1f5f9 ;
width : fit - content ;
}
. desk - tabs button {
border : 0 ;
background : transparent ;
color : # 64748 b ;
font : inherit ;
2026-10-31 13:57:01 +03:30
font - size : 0.75 rem ;
2026-09-28 12:28:14 +03:30
font - weight : 700 ;
2026-10-31 13:57:01 +03:30
padding : 0.22 rem 0.55 rem ;
border - radius : 6 px ;
2026-09-28 12:28:14 +03:30
cursor : pointer ;
2026-10-31 13:57:01 +03:30
white - space : nowrap ;
2026-09-28 12:28:14 +03:30
}
. desk - tabs button . on {
background : # fff ;
color : # 0 f766e ;
box - shadow : 0 1 px 3 px rgba ( 15 , 23 , 42 , 0.08 );
}
2026-10-07 11:18:53 +03:30
. view - row {
display : flex ;
align - items : center ;
justify - content : space - between ;
gap : 0.6 rem ;
flex - wrap : wrap ;
}
. view - row - end {
display : flex ;
align - items : center ;
gap : 0.5 rem ;
2026-10-10 11:58:45 +03:30
flex - wrap : wrap ;
2026-10-07 11:18:53 +03:30
margin - inline - start : auto ;
}
2026-10-20 17:59:34 +03:30
. board - trigger {
display : inline - flex ;
2026-10-20 17:23:27 +03:30
align - items : center ;
2026-10-31 13:57:01 +03:30
gap : 0.3 rem ;
2026-10-20 17:59:34 +03:30
height : 30 px ;
2026-10-31 13:57:01 +03:30
max - width : 12 rem ;
padding : 0 0.5 rem ;
2026-10-20 17:59:34 +03:30
border : 1 px solid var ( -- line );
border - radius : var ( -- radius );
background : var ( -- surface , # fff );
color : var ( -- ink , # 0 f172a );
font : inherit ;
2026-10-31 13:57:01 +03:30
font - size : 0.75 rem ;
2026-10-20 17:59:34 +03:30
font - weight : 700 ;
cursor : pointer ;
2026-10-20 17:23:27 +03:30
}
2026-10-20 17:59:34 +03:30
. board - trigger : hover {
border - color : var ( -- accent , # 0 d9488 );
}
. board - trigger span {
min - width : 0 ;
overflow : hidden ;
text - overflow : ellipsis ;
white - space : nowrap ;
}
. board - chev {
color : var ( -- muted );
transform : rotate ( - 90 deg );
}
. board - menu - sep {
margin : 0.25 rem 0 ;
border : 0 ;
border - top : 1 px solid var ( -- line , # e2e8f0 );
}
. board - menu - action {
display : flex ! important ;
align - items : center ;
gap : 0.4 rem ;
color : var ( -- accent - deep , # 0 f766e ) ! important ;
2026-10-20 17:23:27 +03:30
}
. board - form {
display : grid ;
gap : 0.5 rem ;
}
. board - form - label {
font - size : 0.85 rem ;
font - weight : 700 ;
color : # 334155 ;
}
. board - form - hint {
margin : 0 ;
font - size : 0.8 rem ;
color : var ( -- muted );
line - height : 1.6 ;
}
2026-10-28 20:24:40 +03:30
. board - members {
list - style : none ;
margin : 0 ;
padding : 0.25 rem ;
max - height : 14 rem ;
overflow : auto ;
border : 1 px solid # e2e8f0 ;
border - radius : var ( -- radius );
display : grid ;
gap : 0.1 rem ;
}
. board - members label {
display : flex ;
align - items : center ;
gap : 0.45 rem ;
padding : 0.35 rem 0.45 rem ;
border - radius : var ( -- radius );
cursor : pointer ;
}
. board - members label : hover {
background : # f8fafc ;
}
2026-10-20 17:23:27 +03:30
. board - delete {
justify - self : start ;
color : var ( -- danger );
}
2026-10-07 11:18:53 +03:30
. stage - dot {
display : inline - block ;
width : 0.5 rem ;
height : 0.5 rem ;
border - radius : 999 px ;
flex : 0 0 auto ;
}
. time - chips {
display : flex ;
2026-10-31 13:57:01 +03:30
gap : 0.25 rem ;
2026-10-07 11:18:53 +03:30
flex - wrap : wrap ;
}
. time - chips button {
display : inline - flex ;
align - items : center ;
2026-10-31 13:57:01 +03:30
gap : 0.3 rem ;
height : 30 px ;
2026-10-07 11:18:53 +03:30
border : 1 px solid var ( -- line );
background : # fff ;
color : # 475569 ;
font : inherit ;
2026-10-31 13:57:01 +03:30
font - size : 0.75 rem ;
2026-10-07 11:18:53 +03:30
font - weight : 700 ;
2026-10-31 13:57:01 +03:30
padding : 0 0.5 rem ;
2026-10-07 11:18:53 +03:30
border - radius : 999 px ;
cursor : pointer ;
}
. time - chips button em {
font - style : normal ;
font - size : 0.7 rem ;
color : # 64748 b ;
}
. time - chips button . late em {
color : # dc2626 ;
}
. time - chips button . on {
border - color : # 5 eead4 ;
background : # f0fdfa ;
color : # 0 f766e ;
}
. status - cell {
display : grid ;
gap : 0.25 rem ;
justify - items : start ;
min - width : 0 ;
}
. stage - name {
display : inline - flex ;
align - items : center ;
gap : 0.3 rem ;
color : # 64748 b ;
font - size : 0.72 rem ;
max - width : 100 % ;
overflow : hidden ;
text - overflow : ellipsis ;
white - space : nowrap ;
}
2026-08-29 21:06:30 +03:30
. tabs {
display : flex ;
2026-09-02 21:29:00 +03:30
gap : 0.2 rem ;
overflow - x : auto ;
border - bottom : 1 px solid # e2e8f0 ;
2026-08-29 21:06:30 +03:30
}
. tabs button {
2026-09-02 21:29:00 +03:30
display : inline - flex ;
align - items : center ;
gap : 0.4 rem ;
2026-08-29 21:06:30 +03:30
border : 0 ;
2026-09-02 21:29:00 +03:30
background : none ;
padding : 0.7 rem 0.85 rem ;
color : # 64748 b ;
font - weight : 700 ;
2026-08-29 21:06:30 +03:30
white - space : nowrap ;
2026-09-02 21:29:00 +03:30
cursor : pointer ;
box - shadow : inset 0 - 2 px 0 transparent ;
2026-08-29 21:06:30 +03:30
}
2026-09-02 21:29:00 +03:30
. tabs button em {
font - style : normal ;
font - size : 0.72 rem ;
background : # f1f5f9 ;
border - radius : 999 px ;
padding : 0.05 rem 0.4 rem ;
}
. tabs button . on {
color : # 0 f766e ;
box - shadow : inset 0 - 2 px 0 # 14 b8a6 ;
}
. tabs button . on em {
background : # ccfbf1 ;
2026-08-29 21:06:30 +03:30
}
. search {
2026-09-12 01:33:07 +03:30
flex : 1 1 200 px ;
min - width : 180 px ;
max - width : 400 px ;
height : var ( -- control - h );
min - height : var ( -- control - h );
2026-09-02 21:29:00 +03:30
display : flex ;
align - items : center ;
gap : 0.5 rem ;
background : # fff ;
2026-09-12 01:33:07 +03:30
border : 1 px solid var ( -- line );
border - radius : var ( -- radius );
padding : 0 0.75 rem ;
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 2 px var ( -- accent - soft );
2026-08-29 21:06:30 +03:30
}
. search input {
flex : 1 ;
2026-09-02 21:29:00 +03:30
min - width : 0 ;
border : 0 ;
2026-09-12 01:33:07 +03:30
outline : none ;
2026-09-02 21:29:00 +03:30
background : none ;
2026-09-12 01:33:07 +03:30
padding : 0 ;
2026-09-02 21:29:00 +03:30
font : inherit ;
2026-09-12 01:33:07 +03:30
font - size : var ( -- fs - sm );
color : var ( -- ink );
2026-09-02 21:29:00 +03:30
}
. filter - wrap ,
. row - more ,
. card - side {
position : relative ;
}
. filter - btn . chev {
transform : rotate ( 90 deg );
}
2026-10-03 12:00:46 +03:30
. filter - btn . on {
border - color : # 99 f6e4 ;
background : # f0fdfa ;
color : # 0 f766e ;
}
2026-09-02 21:29:00 +03:30
. popover {
position : absolute ;
top : calc ( 100 % + 6 px );
inset - inline - end : 0 ;
min - width : 11 rem ;
2026-08-29 21:06:30 +03:30
background : # fff ;
2026-09-12 01:33:07 +03:30
border - radius : var ( -- radius );
2026-09-02 21:29:00 +03:30
box - shadow : 0 16 px 40 px rgba ( 15 , 23 , 42 , 0.14 );
padding : 0.35 rem ;
2026-08-29 21:06:30 +03:30
display : grid ;
2026-09-09 13:12:13 +03:30
z - index : 20 ;
}
2026-10-03 12:00:46 +03:30
. filter - popover {
2026-11-01 21:20:42 +03:30
width : min ( 20 rem , calc ( 100 vw - 1.5 rem ));
max - height : min ( 70 vh , 32 rem );
2026-10-03 12:00:46 +03:30
overflow : auto ;
2026-11-01 21:20:42 +03:30
padding : 0.7 rem 0.75 rem 0.55 rem ;
2026-10-03 12:00:46 +03:30
}
. facet - title {
margin : 0.45 rem 0.35 rem 0.15 rem ;
font - size : 0.72 rem ;
font - weight : 700 ;
color : # 64748 b ;
}
. facet - title : first - child {
margin - top : 0.15 rem ;
}
. clear - facets {
margin - top : 0.25 rem ;
border - top : 1 px solid # e2e8f0 ! important ;
border - radius : 0 0 var ( -- radius ) var ( -- radius ) ! important ;
color : # b91c1c ! important ;
}
2026-09-02 21:29:00 +03:30
. popover a ,
. popover button {
display : block ;
width : 100 % ;
text - align : start ;
border : 0 ;
background : none ;
padding : 0.6 rem 0.7 rem ;
2026-09-12 01:33:07 +03:30
border - radius : var ( -- radius );
2026-09-02 21:29:00 +03:30
font : inherit ;
cursor : pointer ;
color : # 0 f172a ;
}
. popover a : hover ,
. popover button : hover ,
. popover button . on {
background : # f0fdfa ;
color : # 0 f766e ;
}
. empty - card {
background : # fff ;
2026-09-12 01:33:07 +03:30
border - radius : var ( -- radius );
2026-09-02 21:29:00 +03:30
box - shadow : 0 10 px 28 px rgba ( 15 , 23 , 42 , 0.05 );
padding : 2.4 rem 1.2 rem 2 rem ;
display : grid ;
justify - items : center ;
text - align : center ;
2026-08-29 21:06:30 +03:30
gap : 0.7 rem ;
}
2026-09-02 21:29:00 +03:30
. art svg {
width : 148 px ;
height : auto ;
display : block ;
2026-08-29 21:06:30 +03:30
}
2026-09-02 21:29:00 +03:30
. empty - card h2 {
margin : 0.2 rem 0 0 ;
font - size : 1.15 rem ;
2026-08-29 21:06:30 +03:30
}
2026-09-02 21:29:00 +03:30
. empty - card > p {
max - width : 22 rem ;
color : # 94 a3b8 ;
line - height : 1.8 ;
}
. idea - list {
display : grid ;
gap : 0.4 rem ;
width : min ( 100 % , 320 px );
}
2026-09-10 01:22:24 +03:30
. idea - list a ,
. idea - list button {
border : 0 ;
width : 100 % ;
text - align : right ;
font : inherit ;
cursor : pointer ;
2026-09-02 21:29:00 +03:30
background : # f8fafc ;
2026-09-12 01:33:07 +03:30
border - radius : var ( -- radius );
2026-09-02 21:29:00 +03:30
padding : 0.65 rem 0.8 rem ;
color : # 0 f766e ;
2026-08-29 21:06:30 +03:30
font - weight : 700 ;
}
2026-09-02 21:29:00 +03:30
. list - head {
display : flex ;
justify - content : space - between ;
align - items : center ;
color : # 0 f766e ;
2026-08-29 21:06:30 +03:30
}
2026-09-02 21:29:00 +03:30
. cal - mini {
color : # 0 f766e ;
2026-08-29 21:06:30 +03:30
}
2026-10-06 13:35:13 +03:30
. list - head - tools {
display : flex ;
align - items : center ;
gap : 0.6 rem ;
}
2026-09-02 21:29:00 +03:30
. table {
background : # fff ;
2026-09-12 01:33:07 +03:30
border - radius : var ( -- radius );
2026-09-09 13:12:13 +03:30
overflow : visible ;
2026-09-02 21:29:00 +03:30
box - shadow : 0 8 px 24 px rgba ( 15 , 23 , 42 , 0.04 );
}
. thead ,
. trow {
display : grid ;
2026-11-01 21:20:42 +03:30
grid - template - columns : 2.4 rem minmax ( 0 , 2 fr ) minmax ( 7 rem , 0.9 fr ) minmax ( 7 rem , 1 fr ) 9.2 rem 8.6 rem 9.2 rem 2.4 rem ;
2026-09-02 21:29:00 +03:30
gap : 0.8 rem ;
align - items : center ;
padding : 0.95 rem 1.1 rem ;
}
. thead {
2026-11-01 21:20:42 +03:30
position : relative ;
z - index : 3 ;
2026-09-02 21:29:00 +03:30
color : # 94 a3b8 ;
font - size : 0.78 rem ;
2026-09-12 01:33:07 +03:30
font - weight : 600 ;
2026-09-02 21:29:00 +03:30
border - bottom : 1 px solid # f1f5f9 ;
2026-09-12 01:33:07 +03:30
border - radius : var ( -- radius ) var ( -- radius ) 0 0 ;
2026-09-02 21:29:00 +03:30
}
2026-11-01 21:20:42 +03:30
. th - cell {
position : relative ;
display : flex ;
align - items : center ;
gap : 0.2 rem ;
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.35 rem ;
height : 1.35 rem ;
margin : 0 ;
padding : 0 ;
border : 0 ;
border - radius : 6 px ;
background : transparent ;
color : # 94 a3b8 ;
cursor : pointer ;
}
. th - filter : hover ,
. th - filter . on ,
. th - filter [ aria - expanded = 'true' ] {
color : # 0 f766e ;
background : # f0fdfa ;
}
. col - popover {
inset - inline - start : 0 ;
inset - inline - end : auto ;
width : 16.5 rem ;
padding : 0.65 rem 0.7 rem ;
}
. col - popover . align - end {
inset - inline - start : auto ;
inset - inline - end : 0 ;
}
2026-09-02 21:29:00 +03:30
. trow {
2026-09-09 13:12:13 +03:30
position : relative ;
2026-09-02 21:29:00 +03:30
border - bottom : 1 px solid # f8fafc ;
}
2026-10-05 20:00:21 +03:30
. row - num {
color : # 94 a3b8 ;
font - size : 0.78 rem ;
font - variant - numeric : tabular - nums ;
text - align : center ;
}
2026-09-09 13:12:13 +03:30
. trow : last - child {
border - bottom : 0 ;
2026-09-12 01:33:07 +03:30
border - radius : 0 0 var ( -- radius ) var ( -- radius );
2026-09-09 13:12:13 +03:30
}
2026-09-02 21:29:00 +03:30
. title - cell {
display : flex ;
gap : 0.55 rem ;
min - width : 0 ;
2026-10-17 13:26:11 +03:30
max - width : 100 % ;
overflow : hidden ;
}
. title - cell > div {
min - width : 0 ;
flex : 1 ;
overflow : hidden ;
2026-09-02 21:29:00 +03:30
}
. star {
color : # cbd5e1 ;
margin - top : 0.2 rem ;
2026-10-17 13:26:11 +03:30
flex : 0 0 auto ;
2026-09-02 21:29:00 +03:30
}
. title {
2026-10-17 13:26:11 +03:30
display : block ;
2026-08-29 21:06:30 +03:30
color : inherit ;
2026-09-12 01:33:07 +03:30
font - weight : 600 ;
2026-10-17 13:26:11 +03:30
overflow : hidden ;
text - overflow : ellipsis ;
white - space : nowrap ;
2026-08-29 21:06:30 +03:30
}
2026-09-02 21:29:00 +03:30
. sub {
margin : 0.2 rem 0 0 ;
font - size : 0.8 rem ;
2026-10-11 16:48:37 +03:30
overflow : hidden ;
text - overflow : ellipsis ;
white - space : nowrap ;
2026-09-02 21:29:00 +03:30
}
. type - cell {
min - width : 0 ;
}
. type - pill {
display : inline - flex ;
align - items : center ;
gap : 0.28 rem ;
border - radius : 999 px ;
padding : 0.12 rem 0.5 rem ;
font - size : 0.72 rem ;
font - weight : 700 ;
}
. type - pill . call ,
. type - ico . call ,
time . call {
background : # ecfdf5 ;
color : # 0 f766e ;
}
. type - pill . meeting ,
. type - ico . meeting ,
time . meeting {
background : # f5f3ff ;
color : # 6 d28d9 ;
}
. type - pill . task ,
. type - ico . task ,
time . task {
background : # fff7ed ;
color : # c2410c ;
}
. type - pill . note ,
. type - ico . note ,
time . note {
background : # eff6ff ;
color : # 2563 eb ;
}
. when ,
. who {
display : grid ;
gap : 0.15 rem ;
min - width : 0 ;
}
. when strong ,
. who strong {
font - size : 0.9 rem ;
}
. when span ,
. who span {
color : # 94 a3b8 ;
font - size : 0.75 rem ;
display : flex ;
align - items : center ;
gap : 0.25 rem ;
}
2026-10-07 13:17:29 +03:30
. row - tags {
margin : 0.2 rem 0 0 ;
display : flex ;
flex - wrap : wrap ;
gap : 0.25 rem ;
}
. row - tags span {
background : # f0fdfa ;
color : # 0 f766e ;
border - radius : 999 px ;
padding : 0.05 rem 0.45 rem ;
font - size : 0.7 rem ;
font - weight : 600 ;
}
2026-09-02 21:29:00 +03:30
2026-10-07 12:29:23 +03:30
. customer {
display : grid ;
gap : 0.15 rem ;
min - width : 0 ;
}
. customer a {
display : inline - flex ;
align - items : center ;
gap : 0.3 rem ;
min - width : 0 ;
color : var ( -- ink );
font - size : 0.86 rem ;
font - weight : 600 ;
text - decoration : none ;
overflow : hidden ;
text - overflow : ellipsis ;
white - space : nowrap ;
}
. customer a : hover {
color : # 0 f766e ;
}
. customer a . secondary {
color : # 94 a3b8 ;
font - size : 0.75 rem ;
font - weight : 500 ;
}
. customer . none {
color : # cbd5e1 ;
}
2026-09-02 21:29:00 +03:30
. when . late {
color : # dc2626 ;
}
. when . late span {
color : # ef4444 ;
}
. status {
display : inline - flex ;
align - items : center ;
gap : 0.28 rem ;
border - radius : 999 px ;
padding : 0.28 rem 0.55 rem ;
font - size : 0.74 rem ;
font - weight : 700 ;
white - space : nowrap ;
}
. status . done {
background : # dcfce7 ;
color : # 166534 ;
}
. status . progress {
background : # dbeafe ;
color : # 1 d4ed8 ;
}
. status . planned {
background : # ffedd5 ;
color : # c2410c ;
}
. status . overdue ,
. status . canceled {
background : # fee2e2 ;
color : # b91c1c ;
}
. icon - more {
width : 32 px ;
height : 32 px ;
border : 0 ;
background : none ;
color : # 64748 b ;
2026-09-12 01:33:07 +03:30
border - radius : var ( -- radius );
2026-09-02 21:29:00 +03:30
display : grid ;
place - items : center ;
cursor : pointer ;
}
2026-10-17 12:47:07 +03:30
. icon - more . trash {
color : var ( -- danger , # b91c1c );
}
. tick - col {
display : grid ;
place - items : center ;
}
. tick {
width : 1.7 rem ;
height : 1.7 rem ;
border : 1.5 px solid # cbd5e1 ;
border - radius : 999 px ;
background : # fff ;
color : # fff ;
display : grid ;
place - items : center ;
padding : 0 ;
cursor : pointer ;
}
. tick . on {
border - color : var ( -- accent , # 14 b8a6 );
background : var ( -- accent , # 14 b8a6 );
}
. trow . selected ,
. card . selected {
background : color - mix ( in srgb , var ( -- accent , # 14 b8a6 ) 6 % , # fff );
}
. trow . select - mode {
cursor : pointer ;
}
. select - bar {
display : flex ;
align - items : center ;
flex - wrap : wrap ;
gap : 0.65 rem 1 rem ;
padding : 0.55 rem 0.85 rem ;
border - radius : var ( -- radius );
background : color - mix ( in srgb , var ( -- accent , # 14 b8a6 ) 10 % , # fff );
border : 1 px solid color - mix ( in srgb , var ( -- accent , # 14 b8a6 ) 25 % , # e2e8f0 );
font - size : 0.86 rem ;
font - weight : 700 ;
color : var ( -- ink , # 0 f172a );
}
. select - bar span {
flex : 1 ;
min - width : 8 rem ;
}
. select - bar button {
border : 0 ;
background : none ;
padding : 0 ;
font : inherit ;
font - size : 0.82 rem ;
font - weight : 600 ;
cursor : pointer ;
}
. select - bar . select - del {
color : var ( -- danger , # b91c1c );
}
. select - bar . select - cancel {
color : var ( -- accent , # 0 f766e );
}
. menu - pop {
position : fixed ;
2026-10-19 11:40:25 +03:30
z - index : 1200 ;
2026-10-17 12:47:07 +03:30
background : # fff ;
border : 1 px solid var ( -- line , # e2e8f0 );
border - radius : var ( -- radius );
box - shadow : var ( -- shadow , 0 16 px 40 px rgba ( 15 , 23 , 42 , 0.14 ));
padding : 0.35 rem ;
display : grid ;
gap : 0.15 rem ;
min - width : 10 rem ;
}
. menu - pop button ,
. menu - pop a {
border : 0 ;
background : transparent ;
padding : 0.45 rem 0.85 rem ;
cursor : pointer ;
font : inherit ;
color : var ( -- ink , # 0 f172a );
text - align : right ;
text - decoration : none ;
border - radius : calc ( var ( -- radius ) - 4 px );
}
. 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 ;
}
2026-09-02 21:29:00 +03:30
. cards {
display : grid ;
gap : 0.75 rem ;
}
. card {
background : # fff ;
border : 1 px solid # eef2f6 ;
2026-09-12 01:33:07 +03:30
border - radius : var ( -- radius );
2026-09-02 21:29:00 +03:30
padding : 0.9 rem 0.95 rem 0.75 rem ;
}
. card - top {
display : flex ;
gap : 0.7 rem ;
justify - content : space - between ;
}
. card - main {
min - width : 0 ;
flex : 1 ;
}
. card - main time {
display : inline - block ;
background : none ;
2026-09-12 01:33:07 +03:30
font - weight : 600 ;
2026-09-02 21:29:00 +03:30
font - size : 0.95 rem ;
margin - bottom : 0.2 rem ;
}
. card - main . title {
display : flex ;
align - items : flex - start ;
gap : 0.3 rem ;
}
. meta - line {
display : flex ;
flex - wrap : wrap ;
gap : 0.7 rem ;
margin : 0.45 rem 0 0 ;
color : # 64748 b ;
font - size : 0.78 rem ;
}
. meta - line span {
display : inline - flex ;
align - items : center ;
gap : 0.25 rem ;
}
. card - side {
display : grid ;
justify - items : center ;
gap : 0.35 rem ;
2026-08-29 21:06:30 +03:30
flex - shrink : 0 ;
}
2026-09-02 21:29:00 +03:30
. type - ico {
width : 42 px ;
height : 42 px ;
border - radius : 999 px ;
display : grid ;
place - items : center ;
2026-08-29 21:06:30 +03:30
}
2026-09-02 21:29:00 +03:30
. card - foot {
display : flex ;
justify - content : space - between ;
align - items : center ;
margin - top : 0.75 rem ;
padding - top : 0.7 rem ;
border - top : 1 px solid # f1f5f9 ;
color : # 64748 b ;
font - size : 0.78 rem ;
}
. card - foot span {
display : inline - flex ;
align - items : center ;
gap : 0.3 rem ;
}
. done - btn {
display : inline - flex ;
align - items : center ;
gap : 0.3 rem ;
border : 0 ;
background : # ecfdf5 ;
color : # 0 f766e ;
border - radius : 999 px ;
padding : 0.38 rem 0.7 rem ;
font : inherit ;
font - weight : 700 ;
cursor : pointer ;
}
. pager {
display : flex ;
justify - content : space - between ;
align - items : center ;
gap : 0.8 rem ;
flex - wrap : wrap ;
color : # 64748 b ;
font - size : 0.8 rem ;
}
. pages {
display : flex ;
gap : 0.3 rem ;
}
. pages button {
min - width : 32 px ;
height : 32 px ;
border : 0 ;
background : # fff ;
2026-09-12 01:33:07 +03:30
border - radius : var ( -- radius );
2026-09-02 21:29:00 +03:30
cursor : pointer ;
}
. pages button . on {
background : # 14 b8a6 ;
color : # fff ;
}
. pages button : disabled {
opacity : 0.4 ;
}
. mobile - only {
display : none ;
}
2026-10-31 15:05:05 +03:30
. excel - icon {
display : none ;
}
. actions - more {
display : none ;
}
. desk - break {
display : none ;
}
. desk - actions - desktop {
display : flex ;
align - items : center ;
flex - wrap : nowrap ;
gap : 6 px ;
min - width : 0 ;
}
. more - trigger {
display : inline - flex ;
align - items : center ;
justify - content : center ;
width : 30 px ;
height : 30 px ;
padding : 0 ;
border : 1 px solid var ( -- line );
border - radius : var ( -- radius );
background : # fff ;
color : # 475569 ;
cursor : pointer ;
}
. actions - menu {
display : grid ;
gap : 1 px ;
}
. actions - menu . menu - row {
display : flex ;
align - items : center ;
gap : 0.45 rem ;
width : 100 % ;
border : 0 ;
background : transparent ;
text - align : right ;
padding : 0.55 rem 0.7 rem ;
border - radius : var ( -- radius , 10 px );
font : inherit ;
font - size : 0.875 rem ;
color : var ( -- ink , # 0 f172a );
cursor : pointer ;
text - decoration : none ;
}
. actions - menu . menu - row : hover ,
. actions - menu . menu - row . on {
background : var ( -- accent - soft , # ecfdf8 );
color : var ( -- accent - deep , # 0 f766e );
}
. actions - menu . menu - row : disabled {
opacity : 0.45 ;
cursor : default ;
}
. import - proxy {
position : absolute ;
width : 1 px ;
height : 1 px ;
overflow : hidden ;
clip : rect ( 0 , 0 , 0 , 0 );
}
2026-09-02 21:29:00 +03:30
@ media ( max - width : 899 px ) {
. crumbs ,
. desktop - only ,
. tabs button em ,
. pager . per {
display : none ;
}
. mobile - only {
display : grid ;
2026-08-29 21:06:30 +03:30
}
. page - head {
flex - direction : column ;
2026-10-31 13:57:01 +03:30
align - items : stretch ;
}
2026-10-31 15:05:05 +03:30
. desk - chrome {
display : flex ;
flex - direction : row ;
2026-10-31 13:57:01 +03:30
flex - wrap : wrap ;
2026-10-31 15:05:05 +03:30
align - items : center ;
column - gap : 4 px ;
row - gap : 8 px ;
}
. 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.5 rem ;
}
. desk - break {
display : block ;
order : 5 ;
flex : 1 0 100 % ;
height : 0 ;
margin - bottom : - 8 px ;
}
. desk - actions {
order : 8 ;
flex : 0 0 30 px ;
width : 30 px ;
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 : 4 px ;
}
. desk - tabs button {
padding : 0.16 rem 0.35 rem ;
font - size : 0.68 rem ;
}
. desk - bar . search {
flex : 1 1 auto ;
width : auto ;
min - width : 0 ;
max - width : none ;
height : 30 px ;
min - height : 30 px ;
}
. desk - bar . filter - btn {
flex : 0 0 30 px ;
width : 30 px ;
min - width : 30 px ;
height : 30 px ;
min - height : 30 px ;
padding : 0 ;
gap : 0 ;
}
. desk - bar . filter - label ,
. desk - bar . filter - btn . chev {
display : none ;
}
. time - chips {
flex - wrap : nowrap ;
min - width : 0 ;
gap : 2 px ;
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 : 28 px ;
padding : 0 0.25 rem ;
font - size : 0.68 rem ;
gap : 0.15 rem ;
white - space : nowrap ;
scroll - snap - align : start ;
}
. board - trigger {
width : auto ;
max - width : 8.5 rem ;
padding - inline : 0.35 rem ;
font - size : 0.75 rem ;
2026-08-29 21:06:30 +03:30
}
. head - actions {
width : 100 % ;
}
. head - actions . btn {
2026-09-02 21:29:00 +03:30
flex : 1 ;
2026-08-29 21:06:30 +03:30
}
2026-10-10 11:58:45 +03:30
. btn - full {
2026-09-15 17:33:05 +03:30
display : none ;
}
2026-10-10 11:58:45 +03:30
. btn - short {
2026-09-15 17:33:05 +03:30
display : inline ;
}
2026-09-02 21:29:00 +03:30
h1 {
font - size : 1.55 rem ;
2026-08-29 21:06:30 +03:30
}
. tabs {
2026-09-02 21:29:00 +03:30
border - bottom : 0 ;
gap : 0.4 rem ;
2026-08-29 21:06:30 +03:30
}
. tabs button {
2026-09-02 21:29:00 +03:30
background : # fff ;
border - radius : 999 px ;
box - shadow : none ;
padding : 0.48 rem 0.8 rem ;
2026-08-29 21:06:30 +03:30
}
2026-09-02 21:29:00 +03:30
. tabs button . on {
background : # ccfbf1 ;
box - shadow : none ;
2026-08-29 21:06:30 +03:30
}
2026-09-02 21:29:00 +03:30
. tools {
2026-08-29 21:06:30 +03:30
align - items : stretch ;
}
2026-09-02 21:29:00 +03:30
. search {
flex : 1 1 auto ;
}
. list - head {
padding - top : 0.2 rem ;
}
}
@ media ( max - width : 599 px ) {
. thead ,
. trow {
grid - template - columns : 1 fr ;
2026-08-29 21:06:30 +03:30
}
2026-10-05 20:00:21 +03:30
. row - num {
display : none ;
}
2026-08-29 21:06:30 +03:30
}
</ style >