fix(web): pack the activity desk header so the columns stay visible

The toolbar keeps every control, just on two shorter rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-31 13:57:01 +03:30
co-authored by Cursor
parent d7b5085096
commit 8b2777be4e
+366 -206
View File
@@ -16,11 +16,9 @@ import {
UiDropdownMenu,
UiInput,
UiModal,
UiPageHeader,
UiPagination,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import UiSegmented from '@/components/ui/UiSegmented.vue';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import ActivitiesKanban from '@/components/activities/ActivitiesKanban.vue';
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
@@ -105,11 +103,6 @@ const TIME_TABS: Array<{ key: TimeFilter; label: string }> = [
{ key: 'all', label: 'همه زمان‌ها' },
];
const TIME_KEYS: TimeFilter[] = TIME_TABS.map((t) => t.key);
const VIEW_OPTIONS = [
{ value: 'tabs' as const, label: 'نمای تب', icon: 'list' as const },
{ value: 'board' as const, label: 'نمای ستونی', icon: 'grid' as const },
];
const VIEW_STORAGE_KEY = 'activities-view-mode';
const DESK_TABS: Array<{ key: DeskScope; label: string }> = [
@@ -666,11 +659,6 @@ function setViewMode(next: ViewMode) {
});
}
const viewModel = computed<ViewMode>({
get: () => viewMode.value,
set: (v) => setViewMode(v),
});
function onStagesChanged() {
void loadStages().then(() => loadCounts());
}
@@ -1013,69 +1001,215 @@ onUnmounted(() => {
<template>
<section class="page" @click="closePopovers">
<UiBusyOverlay :open="pageBusy" :message="busyMessage" />
<nav class="crumbs" aria-label="مسیر">
<RouterLink :to="{ name: 'home' }">خانه</RouterLink>
<span>‹</span>
<span>میز کار</span>
</nav>
<UiPageHeader
title="میز کار و فعالیت‌ها"
lead="کارهای امروز، ارجاع به همکار و پیگیری وضعیت."
>
<template #actions>
<UiButton variant="ghost" type="button" :disabled="busy" :loading="pageBusy" @click.stop="exportExcel">
<span class="btn-full">خروجی اکسل</span>
<span class="btn-short">خروجی</span>
</UiButton>
<ExcelImportFlow
preview-path="/activities/import/preview"
import-path="/activities/import"
rows-import-path="/activities/import/rows"
:client-aliases="ACTIVITY_EXCEL_ALIASES"
:client-fields="ACTIVITY_EXCEL_FIELDS"
:client-read-options="activityClientReadOptions"
:disabled="busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<template #default="{ loading }">
<UiButton variant="ghost" type="button" :disabled="busy" :loading="loading">
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</UiButton>
</template>
</ExcelImportFlow>
<UiButton variant="primary" @click="activityModal = {}">
<UiIcon name="plus" :size="16" />
فعالیت جدید
</UiButton>
<RouterLink :to="{ name: 'activities-calendar' }">
<UiButton variant="ghost">
<UiIcon name="calendar" :size="16" />
تقویم
<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">
<UiButton
v-if="canManageStages"
variant="ghost"
size="sm"
type="button"
@click="typesManagerOpen = true"
>
<UiIcon name="sliders" :size="14" />
انواع فعالیت و فیلدها
</UiButton>
</RouterLink>
</template>
</UiPageHeader>
<div class="view-row">
<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>
<UiButton
v-if="canManageStages"
variant="ghost"
size="sm"
type="button"
@click="stagesManagerOpen = true"
>
<UiIcon name="settings" :size="14" />
مدیریت ستون‌ها
</UiButton>
<UiButton variant="ghost" size="sm" type="button" :disabled="busy" :loading="pageBusy" @click.stop="exportExcel">
<span class="btn-full">خروجی اکسل</span>
<span class="btn-short">خروجی</span>
</UiButton>
<ExcelImportFlow
preview-path="/activities/import/preview"
import-path="/activities/import"
rows-import-path="/activities/import/rows"
:client-aliases="ACTIVITY_EXCEL_ALIASES"
:client-fields="ACTIVITY_EXCEL_FIELDS"
:client-read-options="activityClientReadOptions"
:disabled="busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<template #default="{ loading }">
<UiButton variant="ghost" size="sm" type="button" :disabled="busy" :loading="loading">
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</UiButton>
</template>
</ExcelImportFlow>
<UiButton
variant="primary"
size="sm"
class="icon-only"
title="فعالیت جدید"
aria-label="فعالیت جدید"
@click="activityModal = {}"
>
<UiIcon name="plus" :size="16" />
</UiButton>
<RouterLink :to="{ name: 'activities-calendar' }">
<UiButton
variant="ghost"
size="sm"
class="icon-only"
title="تقویم"
aria-label="تقویم"
>
<UiIcon name="calendar" :size="16" />
</UiButton>
</RouterLink>
<div class="view-toggle" role="group" aria-label="نوع نما">
<button
type="button"
class="seg"
:class="{ on: viewMode === 'tabs' }"
title="نمای تب"
aria-label="نمای تب"
:aria-pressed="viewMode === 'tabs'"
@click="setViewMode('tabs')"
>
<UiIcon name="list" :size="16" />
</button>
<button
type="button"
class="seg"
:class="{ on: viewMode === 'board' }"
title="نمای ستونی"
aria-label="نمای ستونی"
:aria-pressed="viewMode === 'board'"
@click="setViewMode('board')"
>
<UiIcon name="grid" :size="16" />
</button>
</div>
</div>
</div>
<div class="view-row-end">
<div class="desk-bar">
<div v-if="showDeskTabs" class="desk-tabs" role="tablist" aria-label="محدوده میز کار">
<button
v-for="tab in DESK_TABS"
:key="tab.key"
type="button"
role="tab"
:class="{ on: desk === tab.key }"
:aria-selected="desk === tab.key"
@click="setDesk(tab.key)"
>
{{ tab.label }}
</button>
</div>
<div class="tools">
<form class="search" @submit.prevent="onSearch">
<UiIcon name="search" :size="16" />
<input
v-model="q"
type="search"
placeholder="جستجو در عنوان، مشتری، کارشناس، نوع فعالیت..."
/>
</form>
<div class="filter-wrap" @click.stop>
<button
class="btn ghost filter-btn"
type="button"
:class="{ on: activeFacetCount > 0 }"
@click="filterOpen = !filterOpen"
>
<UiIcon name="filter" :size="15" />
{{ filterBtnLabel }}
<UiIcon name="chevron" :size="13" class="chev" />
</button>
<div v-if="filterOpen" class="popover filter-popover">
<p class="facet-title">نوع کار</p>
<button
v-for="opt in TYPE_FILTERS"
:key="`type-${opt.key}`"
type="button"
:class="{ on: typeFilter === opt.key }"
@click="setTypeFilter(opt.key)"
>
{{ opt.label }}
</button>
<p class="facet-title">وضعیت</p>
<button
v-for="opt in STATUS_FILTERS"
:key="`status-${opt.key}`"
type="button"
:class="{ on: statusFilter === opt.key }"
@click="setStatusFilter(opt.key)"
>
{{ opt.label }}
</button>
<template v-if="showDeskTabs">
<p class="facet-title">کارشناس</p>
<button
type="button"
:class="{ on: !assigneeFilter }"
@click="setAssigneeFilter('')"
>
همه کارشناسان
</button>
<button
v-for="user in users"
:key="user.id"
type="button"
:class="{ on: assigneeFilter === user.id }"
@click="setAssigneeFilter(user.id)"
>
{{ user.fullName }}
</button>
</template>
<button
v-if="activeFacetCount"
type="button"
class="clear-facets"
@click="clearFacets"
>
پاک کردن فیلترها
</button>
</div>
</div>
</div>
<div class="time-chips" role="tablist" aria-label="فیلتر زمانی">
<button
v-for="tab in TIME_TABS"
:key="tab.key"
type="button"
role="tab"
:class="{ on: filter === tab.key, late: tab.key === 'overdue' }"
:aria-selected="filter === tab.key"
@click="setFilter(tab.key)"
>
{{ tab.label }}
<em>{{ toFaDigits(counts[tab.key].toLocaleString('en-US')) }}</em>
</button>
</div>
<UiDropdownMenu
v-if="boards.length"
:open="boardMenuOpen"
@@ -1116,35 +1250,9 @@ onUnmounted(() => {
</button>
</template>
</UiDropdownMenu>
<UiButton
v-if="canManageStages"
variant="ghost"
size="sm"
type="button"
@click="stagesManagerOpen = true"
>
<UiIcon name="settings" :size="15" />
مدیریت ستون‌ها
</UiButton>
<UiButton
v-if="canManageStages"
variant="ghost"
size="sm"
type="button"
@click="typesManagerOpen = true"
>
<UiIcon name="sliders" :size="15" />
انواع فعالیت و فیلدها
</UiButton>
<UiSegmented v-model="viewModel" :options="VIEW_OPTIONS">
<template #icon="{ option }">
<UiIcon :name="option.icon ?? 'list'" :size="15" />
</template>
</UiSegmented>
</div>
</div>
<div v-if="viewMode === 'tabs'" class="tabs" role="tablist" aria-label="ستون‌های فعالیت">
<div v-if="viewMode === 'tabs'" class="tabs" role="tablist" aria-label="ستون‌های فعالیت">
<button
type="button"
role="tab"
@@ -1169,95 +1277,6 @@ onUnmounted(() => {
<em>{{ toFaDigits((stageCounts.counts[s.id] ?? 0).toLocaleString('en-US')) }}</em>
</button>
</div>
<div class="time-chips" role="tablist" aria-label="فیلتر زمانی">
<button
v-for="tab in TIME_TABS"
:key="tab.key"
type="button"
role="tab"
:class="{ on: filter === tab.key, late: tab.key === 'overdue' }"
:aria-selected="filter === tab.key"
@click="setFilter(tab.key)"
>
{{ tab.label }}
<em>{{ toFaDigits(counts[tab.key].toLocaleString('en-US')) }}</em>
</button>
</div>
<div class="tools">
<form class="search" @submit.prevent="onSearch">
<UiIcon name="search" :size="18" />
<input
v-model="q"
type="search"
placeholder="جستجو در عنوان، مشتری، کارشناس، نوع فعالیت..."
/>
</form>
<div class="filter-wrap" @click.stop>
<button
class="btn ghost filter-btn"
type="button"
:class="{ on: activeFacetCount > 0 }"
@click="filterOpen = !filterOpen"
>
<UiIcon name="filter" :size="16" />
{{ filterBtnLabel }}
<UiIcon name="chevron" :size="14" class="chev" />
</button>
<div v-if="filterOpen" class="popover filter-popover">
<p class="facet-title">نوع کار</p>
<button
v-for="opt in TYPE_FILTERS"
:key="`type-${opt.key}`"
type="button"
:class="{ on: typeFilter === opt.key }"
@click="setTypeFilter(opt.key)"
>
{{ opt.label }}
</button>
<p class="facet-title">وضعیت</p>
<button
v-for="opt in STATUS_FILTERS"
:key="`status-${opt.key}`"
type="button"
:class="{ on: statusFilter === opt.key }"
@click="setStatusFilter(opt.key)"
>
{{ opt.label }}
</button>
<template v-if="showDeskTabs">
<p class="facet-title">کارشناس</p>
<button
type="button"
:class="{ on: !assigneeFilter }"
@click="setAssigneeFilter('')"
>
همه کارشناسان
</button>
<button
v-for="user in users"
:key="user.id"
type="button"
:class="{ on: assigneeFilter === user.id }"
@click="setAssigneeFilter(user.id)"
>
{{ user.fullName }}
</button>
</template>
<button
v-if="activeFacetCount"
type="button"
class="clear-facets"
@click="clearFacets"
>
پاک کردن فیلترها
</button>
</div>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
@@ -1784,18 +1803,148 @@ onUnmounted(() => {
.page {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0.75rem;
min-width: 0;
min-height: 0;
height: 100%;
}
.crumbs {
.desk-chrome {
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
min-width: 0;
}
.desk-top {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px 10px;
flex-wrap: nowrap;
min-width: 0;
}
.desk-title {
min-width: 0;
flex: 0 1 auto;
}
.desk-title h1 {
margin: 0;
font-size: 1.15rem;
font-weight: 800;
line-height: 1.25;
}
.desk-title .lead {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.35rem;
margin: 0;
color: #64748b;
font-size: 0.75rem;
line-height: 1.35;
min-width: 0;
overflow: hidden;
}
.desk-title .lead > span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lead-sep {
color: #cbd5e1;
}
.desk-actions {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 6px;
margin-inline-start: auto;
flex: 0 1 auto;
min-width: 0;
}
.desk-actions :deep(.icon-only) {
width: 30px;
min-width: 30px;
padding: 0;
}
.view-toggle {
display: inline-flex;
align-items: center;
height: 30px;
padding: 2px;
gap: 2px;
background: #e8eef1;
border-radius: var(--radius);
}
.view-toggle .seg {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: #7b8794;
cursor: pointer;
}
.view-toggle .seg.on {
background: #fff;
color: var(--accent-deep);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
}
.desk-bar {
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.desk-bar .tools {
flex: 0 1 340px;
min-width: 200px;
gap: 6px;
}
.desk-bar .search {
flex: 1 1 160px;
max-width: 280px;
height: 30px;
min-height: 30px;
padding: 0 0.55rem;
font-size: 0.78rem;
}
.desk-bar .filter-btn {
height: 30px;
min-height: 30px;
padding: 0 0.55rem;
font-size: 0.78rem;
}
.crumbs {
display: inline-flex;
gap: 0.3rem;
align-items: center;
color: #94a3b8;
font-size: 0.78rem;
font-size: inherit;
}
.crumbs a {
@@ -1896,9 +2045,10 @@ h1 {
.desk-tabs {
display: inline-flex;
gap: 0.25rem;
padding: 0.2rem;
border-radius: 12px;
flex: 0 0 auto;
gap: 0.15rem;
padding: 2px;
border-radius: 8px;
background: #f1f5f9;
width: fit-content;
}
@@ -1908,11 +2058,12 @@ h1 {
background: transparent;
color: #64748b;
font: inherit;
font-size: 0.86rem;
font-size: 0.75rem;
font-weight: 700;
padding: 0.45rem 0.85rem;
border-radius: 10px;
padding: 0.22rem 0.55rem;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
}
.desk-tabs button.on {
@@ -1940,16 +2091,16 @@ h1 {
.board-trigger {
display: inline-flex;
align-items: center;
gap: 0.4rem;
gap: 0.3rem;
height: 30px;
max-width: 14rem;
padding: 0 0.6rem;
max-width: 12rem;
padding: 0 0.5rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface, #fff);
color: var(--ink, #0f172a);
font: inherit;
font-size: 0.82rem;
font-size: 0.75rem;
font-weight: 700;
cursor: pointer;
}
@@ -2041,21 +2192,22 @@ h1 {
.time-chips {
display: flex;
gap: 0.35rem;
gap: 0.25rem;
flex-wrap: wrap;
}
.time-chips button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
gap: 0.3rem;
height: 30px;
border: 1px solid var(--line);
background: #fff;
color: #475569;
font: inherit;
font-size: 0.82rem;
font-size: 0.75rem;
font-weight: 700;
padding: 0.32rem 0.75rem;
padding: 0 0.5rem;
border-radius: 999px;
cursor: pointer;
}
@@ -2810,8 +2962,16 @@ time.note {
display: grid;
}
.desk-top,
.page-head {
flex-direction: column;
align-items: stretch;
}
.desk-actions {
width: 100%;
margin-inline-start: 0;
flex-wrap: wrap;
}
.head-actions {