fix(web): keep the activity desk usable on a phone

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-31 15:05:05 +03:30
co-authored by Cursor
parent 8b2777be4e
commit 8d01bce0c2
2 changed files with 380 additions and 14 deletions
+12
View File
@@ -68,6 +68,8 @@ defineProps<{
| 'pulse'
| 'sliders'
| 'camera'
| 'download'
| 'upload'
| 'arrow-right'
| 'arrow-left'
| 'chevron-right'
@@ -211,6 +213,16 @@ defineProps<{
<path d="M14.5 4.5V9H19" />
<path d="M9 13h6M9 16.5h4" />
</template>
<template v-else-if="name === 'download'">
<path d="M12 4.5v9" />
<path d="M8.5 10.5 12 14l3.5-3.5" />
<path d="M6 17.5h12" />
</template>
<template v-else-if="name === 'upload'">
<path d="M12 14.5v-9" />
<path d="M8.5 8.5 12 5l3.5 3.5" />
<path d="M6 17.5h12" />
</template>
<template v-else-if="name === 'bag'">
<path d="M6.5 8h11l1 12.5H5.5L6.5 8Z" />
<path d="M9 8V6.5A3 3 0 0 1 12 3.5 3 3 0 0 1 15 6.5V8" />
+368 -14
View File
@@ -163,6 +163,12 @@ const board = computed<string>(() => {
});
const currentBoard = computed(() => boards.value.find((b) => b.id === board.value) ?? null);
const boardMenuOpen = ref(false);
const actionsMenuOpen = ref(false);
const importBtn = ref<HTMLButtonElement | null>(null);
function triggerImport() {
importBtn.value?.click();
}
const boardModal = ref<{ mode: 'create' | 'edit'; name: string; memberIds: string[] } | null>(null);
const boardMemberQuery = ref('');
const boardSaving = ref(false);
@@ -965,9 +971,29 @@ function migrateLegacyDoneFilter() {
return true;
}
const timeChipsEl = ref<HTMLElement | null>(null);
function revealActiveTimeChip() {
if (!isMobile.value) return;
const root = timeChipsEl.value;
const active = root?.querySelector<HTMLElement>('button.on');
if (!root || !active) return;
const rootRect = root.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
const fullyVisible =
activeRect.left >= rootRect.left - 1 && activeRect.right <= rootRect.right + 1;
if (fullyVisible) return;
const delta =
activeRect.left < rootRect.left
? activeRect.left - rootRect.left
: activeRect.right - rootRect.right;
root.scrollBy({ left: delta });
}
let mobileQuery: MediaQueryList | null = null;
function syncMobile() {
isMobile.value = mobileQuery?.matches ?? false;
void nextTick(revealActiveTimeChip);
}
onMounted(() => {
@@ -992,6 +1018,14 @@ watch(viewMode, () => {
menuId.value = null;
});
watch(filter, () => {
void nextTick(revealActiveTimeChip);
});
watch(counts, () => {
void nextTick(revealActiveTimeChip);
});
onUnmounted(() => {
mobileQuery?.removeEventListener('change', syncMobile);
unbindBoardMenuDismiss();
@@ -1016,29 +1050,44 @@ onUnmounted(() => {
</p>
</div>
<div class="desk-actions">
<div class="desk-actions-desktop">
<UiButton
v-if="canManageStages"
class="manage-btn"
variant="ghost"
size="sm"
type="button"
title="انواع فعالیت و فیلدها"
@click="typesManagerOpen = true"
>
<UiIcon name="sliders" :size="14" />
انواع فعالیت و فیلدها
<span class="manage-label">انواع فعالیت و فیلدها</span>
</UiButton>
<UiButton
v-if="canManageStages"
class="manage-btn"
variant="ghost"
size="sm"
type="button"
title="مدیریت ستون‌ها"
@click="stagesManagerOpen = true"
>
<UiIcon name="settings" :size="14" />
مدیریت ستون‌ها
<span class="manage-label">مدیریت ستون‌ها</span>
</UiButton>
<UiButton variant="ghost" size="sm" type="button" :disabled="busy" :loading="pageBusy" @click.stop="exportExcel">
<UiButton
class="excel-btn"
variant="ghost"
size="sm"
type="button"
title="خروجی اکسل"
aria-label="خروجی اکسل"
:disabled="busy"
:loading="pageBusy"
@click.stop="exportExcel"
>
<UiIcon class="excel-icon" name="download" :size="16" />
<span class="btn-full">خروجی اکسل</span>
<span class="btn-short">خروجی</span>
</UiButton>
<ExcelImportFlow
preview-path="/activities/import/preview"
@@ -1054,9 +1103,18 @@ onUnmounted(() => {
@settled="onImportSettled"
>
<template #default="{ loading }">
<UiButton variant="ghost" size="sm" type="button" :disabled="busy" :loading="loading">
<UiButton
class="excel-btn"
variant="ghost"
size="sm"
type="button"
title="ورود از اکسل"
aria-label="ورود از اکسل"
:disabled="busy"
:loading="loading"
>
<UiIcon class="excel-icon" name="upload" :size="16" />
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</UiButton>
</template>
</ExcelImportFlow>
@@ -1105,10 +1163,100 @@ onUnmounted(() => {
<UiIcon name="grid" :size="16" />
</button>
</div>
</div>
<div class="actions-more">
<UiDropdownMenu
:open="actionsMenuOpen"
align="start"
@update:open="(v) => (actionsMenuOpen = v)"
>
<template #trigger="{ toggle }">
<button
type="button"
class="more-trigger"
aria-label="گزینه‌های میز کار"
title="گزینه‌های میز کار"
@click.stop="toggle"
>
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<div class="actions-menu">
<button type="button" class="menu-row" @click="activityModal = {}">
<UiIcon name="plus" :size="16" />
فعالیت جدید
</button>
<RouterLink class="menu-row" :to="{ name: 'activities-calendar' }">
<UiIcon name="calendar" :size="16" />
تقویم
</RouterLink>
<button
type="button"
class="menu-row"
:class="{ on: viewMode === 'tabs' }"
@click="setViewMode('tabs')"
>
<UiIcon name="list" :size="16" />
نمای تب
</button>
<button
type="button"
class="menu-row"
:class="{ on: viewMode === 'board' }"
@click="setViewMode('board')"
>
<UiIcon name="grid" :size="16" />
نمای ستونی
</button>
<button type="button" class="menu-row" :disabled="busy" @click="exportExcel">
<UiIcon name="download" :size="16" />
خروجی اکسل
</button>
<button type="button" class="menu-row" :disabled="busy" @click="triggerImport">
<UiIcon name="upload" :size="16" />
ورود از اکسل
</button>
<template v-if="canManageStages">
<hr class="board-menu-sep" />
<button type="button" class="menu-row" @click="typesManagerOpen = true">
<UiIcon name="sliders" :size="16" />
انواع فعالیت و فیلدها
</button>
<button type="button" class="menu-row" @click="stagesManagerOpen = true">
<UiIcon name="settings" :size="16" />
مدیریت ستون‌ها
</button>
</template>
</div>
</UiDropdownMenu>
</div>
<div v-if="isMobile" class="import-proxy" aria-hidden="true">
<ExcelImportFlow
preview-path="/activities/import/preview"
import-path="/activities/import"
rows-import-path="/activities/import/rows"
:client-aliases="ACTIVITY_EXCEL_ALIASES"
:client-fields="ACTIVITY_EXCEL_FIELDS"
:client-read-options="activityClientReadOptions"
:disabled="busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<template #default="{ loading }">
<button ref="importBtn" type="button" tabindex="-1" :disabled="busy || loading">
ورود از اکسل
</button>
</template>
</ExcelImportFlow>
</div>
</div>
</div>
<div class="desk-bar">
<div class="desk-break" aria-hidden="true"></div>
<div class="desk-bar" :class="{ 'has-tabs': showDeskTabs }">
<div v-if="showDeskTabs" class="desk-tabs" role="tablist" aria-label="محدوده میز کار">
<button
v-for="tab in DESK_TABS"
@@ -1128,7 +1276,7 @@ onUnmounted(() => {
<input
v-model="q"
type="search"
placeholder="جستجو در عنوان، مشتری، کارشناس، نوع فعالیت..."
:placeholder="isMobile ? 'جستجو…' : 'جستجو در عنوان، مشتری، کارشناس، نوع فعالیت...'"
/>
</form>
<div class="filter-wrap" @click.stop>
@@ -1136,10 +1284,12 @@ onUnmounted(() => {
class="btn ghost filter-btn"
type="button"
:class="{ on: activeFacetCount > 0 }"
:title="filterBtnLabel"
:aria-label="filterBtnLabel"
@click="filterOpen = !filterOpen"
>
<UiIcon name="filter" :size="15" />
{{ filterBtnLabel }}
<span class="filter-label">{{ filterBtnLabel }}</span>
<UiIcon name="chevron" :size="13" class="chev" />
</button>
<div v-if="filterOpen" class="popover filter-popover">
@@ -1196,7 +1346,7 @@ onUnmounted(() => {
</div>
</div>
</div>
<div class="time-chips" role="tablist" aria-label="فیلتر زمانی">
<div ref="timeChipsEl" class="time-chips" role="tablist" aria-label="فیلتر زمانی">
<button
v-for="tab in TIME_TABS"
:key="tab.key"
@@ -2950,6 +3100,81 @@ time.note {
display: none;
}
.excel-icon {
display: none;
}
.actions-more {
display: none;
}
.desk-break {
display: none;
}
.desk-actions-desktop {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 6px;
min-width: 0;
}
.more-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
color: #475569;
cursor: pointer;
}
.actions-menu {
display: grid;
gap: 1px;
}
.actions-menu .menu-row {
display: flex;
align-items: center;
gap: 0.45rem;
width: 100%;
border: 0;
background: transparent;
text-align: right;
padding: 0.55rem 0.7rem;
border-radius: var(--radius, 10px);
font: inherit;
font-size: 0.875rem;
color: var(--ink, #0f172a);
cursor: pointer;
text-decoration: none;
}
.actions-menu .menu-row:hover,
.actions-menu .menu-row.on {
background: var(--accent-soft, #ecfdf8);
color: var(--accent-deep, #0f766e);
}
.actions-menu .menu-row:disabled {
opacity: 0.45;
cursor: default;
}
.import-proxy {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
@media (max-width: 899px) {
.crumbs,
.desktop-only,
@@ -2962,16 +3187,145 @@ time.note {
display: grid;
}
.desk-top,
.page-head {
flex-direction: column;
align-items: stretch;
}
.desk-actions {
width: 100%;
margin-inline-start: 0;
.desk-chrome {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
column-gap: 4px;
row-gap: 8px;
}
.desk-top,
.desk-bar {
display: contents;
}
.desk-title {
order: 1;
flex: 1 0 100%;
}
.desk-title .lead {
display: none;
}
.desk-actions-desktop {
display: none;
}
.actions-more {
display: inline-flex;
}
.desk-tabs {
order: 2;
flex: 0 0 auto;
}
.time-chips {
order: 3;
flex: 1 1 0;
width: auto;
}
.desk-bar :deep(.ui-dd-anchor) {
order: 7;
flex: 0 0 auto;
min-width: 0;
max-width: 8.5rem;
}
.desk-break {
display: block;
order: 5;
flex: 1 0 100%;
height: 0;
margin-bottom: -8px;
}
.desk-actions {
order: 8;
flex: 0 0 30px;
width: 30px;
margin: 0;
}
.desk-bar .tools,
.desk-bar:not(.has-tabs) .tools {
order: 6;
flex: 1 1 0;
grid-area: auto;
grid-column: auto;
flex-wrap: nowrap;
align-items: center;
min-width: 0;
width: auto;
gap: 4px;
}
.desk-tabs button {
padding: 0.16rem 0.35rem;
font-size: 0.68rem;
}
.desk-bar .search {
flex: 1 1 auto;
width: auto;
min-width: 0;
max-width: none;
height: 30px;
min-height: 30px;
}
.desk-bar .filter-btn {
flex: 0 0 30px;
width: 30px;
min-width: 30px;
height: 30px;
min-height: 30px;
padding: 0;
gap: 0;
}
.desk-bar .filter-label,
.desk-bar .filter-btn .chev {
display: none;
}
.time-chips {
flex-wrap: nowrap;
min-width: 0;
gap: 2px;
overflow-x: auto;
scrollbar-width: none;
scroll-snap-type: x proximity;
}
.time-chips::-webkit-scrollbar {
display: none;
}
.time-chips button {
flex: 0 0 auto;
height: 28px;
padding: 0 0.25rem;
font-size: 0.68rem;
gap: 0.15rem;
white-space: nowrap;
scroll-snap-align: start;
}
.board-trigger {
width: auto;
max-width: 8.5rem;
padding-inline: 0.35rem;
font-size: 0.75rem;
}
.head-actions {