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, UiDropdownMenu,
UiInput, UiInput,
UiModal, UiModal,
UiPageHeader,
UiPagination, UiPagination,
} from '@/components/ui'; } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import UiSegmented from '@/components/ui/UiSegmented.vue';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue'; import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import ActivitiesKanban from '@/components/activities/ActivitiesKanban.vue'; import ActivitiesKanban from '@/components/activities/ActivitiesKanban.vue';
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue'; import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
@@ -105,11 +103,6 @@ const TIME_TABS: Array<{ key: TimeFilter; label: string }> = [
{ key: 'all', label: 'همه زمان‌ها' }, { key: 'all', label: 'همه زمان‌ها' },
]; ];
const TIME_KEYS: TimeFilter[] = TIME_TABS.map((t) => t.key); 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 VIEW_STORAGE_KEY = 'activities-view-mode';
const DESK_TABS: Array<{ key: DeskScope; label: string }> = [ 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() { function onStagesChanged() {
void loadStages().then(() => loadCounts()); void loadStages().then(() => loadCounts());
} }
@@ -1013,69 +1001,215 @@ onUnmounted(() => {
<template> <template>
<section class="page" @click="closePopovers"> <section class="page" @click="closePopovers">
<UiBusyOverlay :open="pageBusy" :message="busyMessage" /> <UiBusyOverlay :open="pageBusy" :message="busyMessage" />
<nav class="crumbs" aria-label="مسیر"> <div class="desk-chrome">
<RouterLink :to="{ name: 'home' }">خانه</RouterLink> <div class="desk-top">
<span>‹</span> <div class="desk-title">
<span>میز کار</span> <h1>میز کار و فعالیت‌ها</h1>
</nav> <p class="lead">
<nav class="crumbs" aria-label="مسیر">
<UiPageHeader <RouterLink :to="{ name: 'home' }">خانه</RouterLink>
title="میز کار و فعالیت‌ها" <span>‹</span>
lead="کارهای امروز، ارجاع به همکار و پیگیری وضعیت." <span>میز کار</span>
> </nav>
<template #actions> <span class="lead-sep" aria-hidden="true">·</span>
<UiButton variant="ghost" type="button" :disabled="busy" :loading="pageBusy" @click.stop="exportExcel"> <span>کارهای امروز، ارجاع به همکار و پیگیری وضعیت.</span>
<span class="btn-full">خروجی اکسل</span> </p>
<span class="btn-short">خروجی</span> </div>
</UiButton> <div class="desk-actions">
<ExcelImportFlow <UiButton
preview-path="/activities/import/preview" v-if="canManageStages"
import-path="/activities/import" variant="ghost"
rows-import-path="/activities/import/rows" size="sm"
:client-aliases="ACTIVITY_EXCEL_ALIASES" type="button"
:client-fields="ACTIVITY_EXCEL_FIELDS" @click="typesManagerOpen = true"
:client-read-options="activityClientReadOptions" >
:disabled="busy" <UiIcon name="sliders" :size="14" />
@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" />
تقویم
</UiButton> </UiButton>
</RouterLink> <UiButton
</template> v-if="canManageStages"
</UiPageHeader> variant="ghost"
size="sm"
<div class="view-row"> type="button"
<div v-if="showDeskTabs" class="desk-tabs" role="tablist" aria-label="محدوده میز کار"> @click="stagesManagerOpen = true"
<button >
v-for="tab in DESK_TABS" <UiIcon name="settings" :size="14" />
:key="tab.key" مدیریت ستون‌ها
type="button" </UiButton>
role="tab" <UiButton variant="ghost" size="sm" type="button" :disabled="busy" :loading="pageBusy" @click.stop="exportExcel">
:class="{ on: desk === tab.key }" <span class="btn-full">خروجی اکسل</span>
:aria-selected="desk === tab.key" <span class="btn-short">خروجی</span>
@click="setDesk(tab.key)" </UiButton>
> <ExcelImportFlow
{{ tab.label }} preview-path="/activities/import/preview"
</button> 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>
<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 <UiDropdownMenu
v-if="boards.length" v-if="boards.length"
:open="boardMenuOpen" :open="boardMenuOpen"
@@ -1116,35 +1250,9 @@ onUnmounted(() => {
</button> </button>
</template> </template>
</UiDropdownMenu> </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>
<div v-if="viewMode === 'tabs'" class="tabs" role="tablist" aria-label="ستون‌های فعالیت"> <div v-if="viewMode === 'tabs'" class="tabs" role="tablist" aria-label="ستون‌های فعالیت">
<button <button
type="button" type="button"
role="tab" role="tab"
@@ -1169,95 +1277,6 @@ onUnmounted(() => {
<em>{{ toFaDigits((stageCounts.counts[s.id] ?? 0).toLocaleString('en-US')) }}</em> <em>{{ toFaDigits((stageCounts.counts[s.id] ?? 0).toLocaleString('en-US')) }}</em>
</button> </button>
</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>
<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> </div>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
@@ -1784,18 +1803,148 @@ onUnmounted(() => {
.page { .page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 0.75rem;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
height: 100%; height: 100%;
} }
.crumbs { .desk-chrome {
display: flex; 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; 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; align-items: center;
color: #94a3b8; color: #94a3b8;
font-size: 0.78rem; font-size: inherit;
} }
.crumbs a { .crumbs a {
@@ -1896,9 +2045,10 @@ h1 {
.desk-tabs { .desk-tabs {
display: inline-flex; display: inline-flex;
gap: 0.25rem; flex: 0 0 auto;
padding: 0.2rem; gap: 0.15rem;
border-radius: 12px; padding: 2px;
border-radius: 8px;
background: #f1f5f9; background: #f1f5f9;
width: fit-content; width: fit-content;
} }
@@ -1908,11 +2058,12 @@ h1 {
background: transparent; background: transparent;
color: #64748b; color: #64748b;
font: inherit; font: inherit;
font-size: 0.86rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
padding: 0.45rem 0.85rem; padding: 0.22rem 0.55rem;
border-radius: 10px; border-radius: 6px;
cursor: pointer; cursor: pointer;
white-space: nowrap;
} }
.desk-tabs button.on { .desk-tabs button.on {
@@ -1940,16 +2091,16 @@ h1 {
.board-trigger { .board-trigger {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.3rem;
height: 30px; height: 30px;
max-width: 14rem; max-width: 12rem;
padding: 0 0.6rem; padding: 0 0.5rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--surface, #fff); background: var(--surface, #fff);
color: var(--ink, #0f172a); color: var(--ink, #0f172a);
font: inherit; font: inherit;
font-size: 0.82rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
} }
@@ -2041,21 +2192,22 @@ h1 {
.time-chips { .time-chips {
display: flex; display: flex;
gap: 0.35rem; gap: 0.25rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.time-chips button { .time-chips button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.3rem;
height: 30px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #fff; background: #fff;
color: #475569; color: #475569;
font: inherit; font: inherit;
font-size: 0.82rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
padding: 0.32rem 0.75rem; padding: 0 0.5rem;
border-radius: 999px; border-radius: 999px;
cursor: pointer; cursor: pointer;
} }
@@ -2810,8 +2962,16 @@ time.note {
display: grid; display: grid;
} }
.desk-top,
.page-head { .page-head {
flex-direction: column; flex-direction: column;
align-items: stretch;
}
.desk-actions {
width: 100%;
margin-inline-start: 0;
flex-wrap: wrap;
} }
.head-actions { .head-actions {