club home redesign; deal multi-select delete; anchored card menus

This commit is contained in:
2026-10-12 17:59:07 +03:30
parent a2b01f787e
commit faac26d07d
6 changed files with 373 additions and 50 deletions
+81 -8
View File
@@ -15,6 +15,8 @@ const props = withDefaults(
accent: PipelineAccent;
dragging?: boolean;
copy?: PipelineUiCopy;
selectMode?: boolean;
selected?: boolean;
}>(),
{ copy: () => DEAL_BOARD_COPY },
);
@@ -24,6 +26,8 @@ const emit = defineEmits<{
dragEnd: [];
open: [dealId: string];
menu: [dealId: string];
toggleSelect: [dealId: string];
bulkDelete: [];
}>();
const showAmount = computed(
@@ -42,30 +46,60 @@ const avatarSrc = computed(() =>
const extraAssignees = computed(() =>
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
);
const showTrash = computed(() => props.selectMode && props.selected);
</script>
<template>
<article
class="deal-card"
:class="{ dragging, 'no-amount': !showAmount }"
:class="{
dragging,
'no-amount': !showAmount,
'select-mode': selectMode,
selected,
}"
:data-card-id="deal.id"
draggable="true"
:draggable="!selectMode"
role="listitem"
:aria-grabbed="dragging ? 'true' : 'false'"
:style="{
'--badge-bg': colors.soft,
'--badge-fg': colors.text,
}"
@dragstart="emit('dragStart', deal, $event)"
@dragstart="!selectMode && emit('dragStart', deal, $event)"
@dragend="emit('dragEnd')"
@dblclick="emit('open', deal.id)"
@dblclick="!selectMode && emit('open', deal.id)"
@click="selectMode && emit('toggleSelect', deal.id)"
>
<span class="badge" aria-hidden="true">{{ deal.initials }}</span>
<button
v-if="selectMode"
type="button"
class="tick"
:class="{ on: selected }"
:aria-label="selected ? 'لغو انتخاب' : 'انتخاب معامله'"
:aria-pressed="selected"
@click.stop="emit('toggleSelect', deal.id)"
>
<UiIcon v-if="selected" name="check" :size="14" />
</button>
<span v-else class="badge" aria-hidden="true">{{ deal.initials }}</span>
<div class="titles">
<h3 @click.stop="emit('open', deal.id)">{{ deal.title }}</h3>
<h3 @click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)">
{{ deal.title }}
</h3>
<p v-if="deal.description" class="desc">{{ deal.description }}</p>
</div>
<button
v-if="showTrash"
type="button"
class="menu trash"
aria-label="حذف انتخاب‌شده‌ها"
@click.stop="emit('bulkDelete')"
>
<UiIcon name="trash" :size="16" />
</button>
<button
v-else
type="button"
class="menu"
:aria-label="copy.itemMenuAria"
@@ -116,11 +150,21 @@ const extraAssignees = computed(() =>
box-shadow 0.18s ease,
border-color 0.18s ease,
transform 0.18s ease,
opacity 0.18s ease;
opacity 0.18s ease,
background 0.18s ease;
text-align: right;
direction: rtl;
}
.deal-card.select-mode {
cursor: pointer;
}
.deal-card.selected {
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
background: color-mix(in srgb, var(--accent) 6%, #fff);
}
.deal-card:hover {
border-color: color-mix(in srgb, var(--accent) 28%, var(--line));
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.07);
@@ -130,6 +174,10 @@ const extraAssignees = computed(() =>
cursor: grabbing;
}
.deal-card.select-mode:active {
cursor: pointer;
}
.deal-card.dragging {
opacity: 0.95;
transform: scale(1.02);
@@ -147,7 +195,8 @@ const extraAssignees = computed(() =>
}
.badge,
.avatar {
.avatar,
.tick {
width: var(--face);
height: var(--face);
max-width: var(--face);
@@ -165,6 +214,21 @@ const extraAssignees = computed(() =>
line-height: 1;
}
.tick {
display: grid;
place-items: center;
border: 2px solid #cbd5e1;
background: #fff;
color: #fff;
padding: 0;
cursor: pointer;
}
.tick.on {
border-color: var(--accent);
background: var(--accent);
}
.avatar {
object-fit: cover;
border: 1px solid var(--line);
@@ -239,6 +303,15 @@ const extraAssignees = computed(() =>
outline: none;
}
.menu.trash {
color: var(--danger);
}
.menu.trash:hover,
.menu.trash:focus-visible {
color: var(--danger);
}
.date {
display: flex;
align-items: center;
@@ -12,8 +12,10 @@ withDefaults(
dropStageId: string | null;
canManage?: boolean;
copy?: PipelineUiCopy;
selectMode?: boolean;
selectedIds?: Set<string>;
}>(),
{ copy: () => DEAL_BOARD_COPY },
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set() },
);
const emit = defineEmits<{
@@ -24,6 +26,8 @@ const emit = defineEmits<{
addDeal: [stageId: string];
openDeal: [dealId: string];
menuDeal: [dealId: string];
toggleSelect: [dealId: string];
bulkDelete: [];
renameStage: [stageId: string, title: string];
deleteStage: [stageId: string];
moveStage: [stageId: string, dir: -1 | 1];
@@ -69,6 +73,8 @@ function submitAdd() {
:drop-active="dropStageId === stage.id"
:can-manage="canManage"
:copy="copy"
:select-mode="selectMode"
:selected-ids="selectedIds"
:is-first="index === 0"
:is-last="index === stages.length - 1"
@drag-start="(d, e) => emit('dragStart', d, e)"
@@ -78,6 +84,8 @@ function submitAdd() {
@add-deal="emit('addDeal', $event)"
@open-deal="emit('openDeal', $event)"
@menu-deal="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
@rename="(id, title) => emit('renameStage', id, title)"
@delete="emit('deleteStage', $event)"
@move="(id, dir) => emit('moveStage', id, dir)"
@@ -15,8 +15,10 @@ const props = withDefaults(
isFirst?: boolean;
isLast?: boolean;
copy?: PipelineUiCopy;
selectMode?: boolean;
selectedIds?: Set<string>;
}>(),
{ copy: () => DEAL_BOARD_COPY },
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set() },
);
const emit = defineEmits<{
@@ -27,6 +29,8 @@ const emit = defineEmits<{
addDeal: [stageId: string];
openDeal: [dealId: string];
menuDeal: [dealId: string];
toggleSelect: [dealId: string];
bulkDelete: [];
rename: [stageId: string, title: string];
delete: [stageId: string];
move: [stageId: string, dir: -1 | 1];
@@ -220,10 +224,14 @@ onBeforeUnmount(() => {
:accent="stage.color"
:dragging="draggingId === deal.id"
:copy="copy"
:select-mode="selectMode"
:selected="selectedIds.has(deal.id)"
@drag-start="(d, e) => emit('dragStart', d, e)"
@drag-end="emit('dragEnd')"
@open="emit('openDeal', $event)"
@menu="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
/>
</div>
<p v-if="!stage.deals.length" class="empty">{{ copy.empty }}</p>
+150 -22
View File
@@ -13,13 +13,17 @@ const props = withDefaults(
defineProps<{
stages: PipelineStage[];
copy?: PipelineUiCopy;
selectMode?: boolean;
selectedIds?: Set<string>;
}>(),
{ copy: () => DEAL_BOARD_COPY },
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set() },
);
const emit = defineEmits<{
openDeal: [dealId: string];
menuDeal: [dealId: string];
toggleSelect: [dealId: string];
bulkDelete: [];
}>();
const rows = computed(() =>
@@ -51,6 +55,7 @@ const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline
<table v-table-cols="tableKey" class="data-table">
<thead>
<tr>
<th v-if="selectMode" class="tick-col"></th>
<th>{{ copy.listColTitle }}</th>
<th>مرحله</th>
<th v-if="!hideAmount">مبلغ</th>
@@ -59,12 +64,34 @@ const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.deal.id">
<tr
v-for="row in rows"
:key="row.deal.id"
:data-card-id="row.deal.id"
:class="{ selected: selectedIds.has(row.deal.id) }"
@click="selectMode && emit('toggleSelect', row.deal.id)"
>
<td v-if="selectMode" class="tick-col" @click.stop>
<button
type="button"
class="tick"
:class="{ on: selectedIds.has(row.deal.id) }"
:aria-label="selectedIds.has(row.deal.id) ? 'لغو انتخاب' : 'انتخاب معامله'"
:aria-pressed="selectedIds.has(row.deal.id)"
@click="emit('toggleSelect', row.deal.id)"
>
<UiIcon v-if="selectedIds.has(row.deal.id)" name="check" :size="14" />
</button>
</td>
<td>
<button
type="button"
class="identity"
@click="emit('openDeal', row.deal.id)"
@click.stop="
selectMode
? emit('toggleSelect', row.deal.id)
: emit('openDeal', row.deal.id)
"
>
<span
class="badge"
@@ -103,8 +130,18 @@ const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline
{{ row.deal.date }}
</span>
</td>
<td class="menu-cell">
<td class="menu-cell" @click.stop>
<button
v-if="selectMode && selectedIds.has(row.deal.id)"
type="button"
class="menu trash"
aria-label="حذف انتخاب‌شده‌ها"
@click="emit('bulkDelete')"
>
<UiIcon name="trash" :size="18" />
</button>
<button
v-else
type="button"
class="menu"
:aria-label="copy.itemMenuAria"
@@ -123,25 +160,62 @@ const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline
v-for="row in rows"
:key="row.deal.id"
class="card"
@dblclick="emit('openDeal', row.deal.id)"
:class="{ selected: selectedIds.has(row.deal.id) }"
:data-card-id="row.deal.id"
@dblclick="!selectMode && emit('openDeal', row.deal.id)"
@click="selectMode && emit('toggleSelect', row.deal.id)"
>
<button
type="button"
class="identity"
@click="emit('openDeal', row.deal.id)"
>
<span
class="badge"
:style="{
background: PIPELINE_ACCENT[row.accent].soft,
color: PIPELINE_ACCENT[row.accent].text,
}"
>{{ row.deal.initials }}</span>
<span class="titles">
<strong>{{ row.deal.title }}</strong>
<em v-if="row.deal.description">{{ row.deal.description }}</em>
</span>
</button>
<div class="card-top">
<button
v-if="selectMode"
type="button"
class="tick"
:class="{ on: selectedIds.has(row.deal.id) }"
:aria-label="selectedIds.has(row.deal.id) ? 'لغو انتخاب' : 'انتخاب معامله'"
@click.stop="emit('toggleSelect', row.deal.id)"
>
<UiIcon v-if="selectedIds.has(row.deal.id)" name="check" :size="14" />
</button>
<button
type="button"
class="identity"
@click.stop="
selectMode
? emit('toggleSelect', row.deal.id)
: emit('openDeal', row.deal.id)
"
>
<span
class="badge"
:style="{
background: PIPELINE_ACCENT[row.accent].soft,
color: PIPELINE_ACCENT[row.accent].text,
}"
>{{ row.deal.initials }}</span>
<span class="titles">
<strong>{{ row.deal.title }}</strong>
<em v-if="row.deal.description">{{ row.deal.description }}</em>
</span>
</button>
<button
v-if="selectMode && selectedIds.has(row.deal.id)"
type="button"
class="menu trash"
aria-label="حذف انتخاب‌شده‌ها"
@click.stop="emit('bulkDelete')"
>
<UiIcon name="trash" :size="18" />
</button>
<button
v-else
type="button"
class="menu"
:aria-label="copy.itemMenuAria"
@click.stop="emit('menuDeal', row.deal.id)"
>
<UiIcon name="dots-v" :size="18" />
</button>
</div>
<span
class="stage"
:style="{
@@ -216,6 +290,34 @@ const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline
background: #f8fafc;
}
.data-table tbody tr.selected td {
background: color-mix(in srgb, var(--accent) 7%, #fff);
}
.tick-col {
width: 2.6rem;
padding-inline: 0.55rem !important;
}
.tick {
width: 1.45rem;
height: 1.45rem;
border-radius: 6px;
border: 2px solid #cbd5e1;
background: #fff;
color: #fff;
display: grid;
place-items: center;
padding: 0;
cursor: pointer;
flex: 0 0 auto;
}
.tick.on {
border-color: var(--accent);
background: var(--accent);
}
.identity {
display: flex;
align-items: center;
@@ -329,6 +431,16 @@ const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline
background: #f1f5f9;
}
.menu.trash {
color: var(--danger);
}
.menu.trash:hover,
.menu.trash:focus-visible {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, #fff);
}
.cards {
display: none;
gap: 0.7rem;
@@ -344,6 +456,22 @@ const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
.card.selected {
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
background: color-mix(in srgb, var(--accent) 6%, #fff);
}
.card-top {
display: flex;
align-items: center;
gap: 0.55rem;
min-width: 0;
}
.card-top .identity {
flex: 1;
}
.card-foot {
display: flex;
align-items: flex-end;
+2 -2
View File
@@ -2,7 +2,7 @@
export function cardMenuStyle(
cardId: string,
width = 160,
height = 190,
height = 230,
): Record<string, string> | null {
const btn = document.querySelector<HTMLElement>(
`[data-card-id="${CSS.escape(cardId)}"] .menu`,
@@ -21,7 +21,7 @@ export function bindMenuDismiss(isOpen: () => boolean, close: () => void): () =>
const onPointer = (e: PointerEvent) => {
if (!isOpen()) return;
const t = e.target as Element | null;
if (t?.closest('.menu-pop, .deal-card .menu')) return;
if (t?.closest('.menu-pop, [data-card-id] .menu')) return;
close();
};
const onKey = (e: KeyboardEvent) => {
+122 -16
View File
@@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliNumeric } from '@/lib/jalali';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import PipelineToolbar from '@/components/pipeline/PipelineToolbar.vue';
import type { PipelineViewMode } from '@/components/pipeline/PipelineToolbar.vue';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
@@ -407,32 +407,79 @@ async function confirmDeleteStage() {
}
}
const deleteDealId = ref<string | null>(null);
const deleteDealIds = ref<string[]>([]);
const dealDeleting = ref(false);
const selectMode = ref(false);
const selectedIds = ref<Set<string>>(new Set());
const selectedCount = computed(() => selectedIds.value.size);
const deleteDealTitle = computed(() => {
const id = deleteDealId.value;
const ids = deleteDealIds.value;
if (ids.length !== 1) return '';
for (const s of stages.value) {
const d = s.deals.find((x) => x.id === id);
const d = s.deals.find((x) => x.id === ids[0]);
if (d) return d.title;
}
return '';
});
const deleteConfirmCopy = computed(() => {
const n = deleteDealIds.value.length;
if (n <= 0) return '';
if (n === 1) {
return `معاملهٔ «${deleteDealTitle.value}» حذف شود؟ این کار قابل بازگشت نیست.`;
}
return `${toFaDigits(n)} معامله حذف شود؟ این کار قابل بازگشت نیست.`;
});
function askDeleteDeal(dealId: string) {
menuOpenId.value = null;
deleteDealId.value = dealId;
deleteDealIds.value = [dealId];
}
function startSelect(dealId: string) {
menuOpenId.value = null;
selectMode.value = true;
selectedIds.value = new Set([dealId]);
}
function toggleSelect(dealId: string) {
const next = new Set(selectedIds.value);
if (next.has(dealId)) next.delete(dealId);
else next.add(dealId);
selectedIds.value = next;
if (!next.size) selectMode.value = false;
}
function clearSelect() {
selectMode.value = false;
selectedIds.value = new Set();
}
function askBulkDelete() {
if (!selectedIds.value.size) return;
menuOpenId.value = null;
deleteDealIds.value = [...selectedIds.value];
}
async function confirmDeleteDeal() {
const id = deleteDealId.value;
if (!id || dealDeleting.value) return;
const ids = deleteDealIds.value;
if (!ids.length || dealDeleting.value) return;
dealDeleting.value = true;
error.value = '';
try {
await api.delete(`/deals/${id}`);
deleteDealId.value = null;
const results = await Promise.allSettled(ids.map((id) => api.delete(`/deals/${id}`)));
const failed = results.filter((r) => r.status === 'rejected').length;
deleteDealIds.value = [];
clearSelect();
await load();
if (failed) {
error.value =
failed === ids.length
? 'حذف معامله‌ها ناموفق بود.'
: `${toFaDigits(failed)} مورد حذف نشد.`;
}
} catch (e) {
error.value = apiMessage(e, 'حذف معامله ناموفق بود.');
} finally {
@@ -591,11 +638,28 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<p v-else-if="loading && !stages.length" class="muted">در حال بارگذاری مسیر فروش…</p>
<div v-if="selectMode" class="select-bar">
<span>{{ toFaDigits(selectedCount) }} معامله انتخاب شده</span>
<button
v-if="selectedCount"
type="button"
class="select-del"
@click="askBulkDelete"
>
حذف انتخاب‌شده‌ها
</button>
<button type="button" class="select-cancel" @click="clearSelect">انصراف از انتخاب</button>
</div>
<PipelineList
v-if="viewMode === 'list' && (!loading || stages.length)"
:stages="visibleStages"
:select-mode="selectMode"
:selected-ids="selectedIds"
@open-deal="onOpenDeal"
@menu-deal="onMenuDeal"
@toggle-select="toggleSelect"
@bulk-delete="askBulkDelete"
/>
<PipelineBoard
@@ -604,6 +668,8 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
:dragging-id="draggingId"
:drop-stage-id="dropStageId"
:can-manage="canCreatePipeline"
:select-mode="selectMode"
:selected-ids="selectedIds"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@drag-over="onDragOver"
@@ -611,6 +677,8 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
@add-deal="onAddDeal"
@open-deal="onOpenDeal"
@menu-deal="onMenuDeal"
@toggle-select="toggleSelect"
@bulk-delete="askBulkDelete"
@rename-stage="onRenameStage"
@delete-stage="onDeleteStage"
@move-stage="onMoveStage"
@@ -630,6 +698,9 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
</Teleport>
<div v-if="menuOpenId" class="menu-pop" role="menu" :style="menuStyle ?? undefined">
<button type="button" role="menuitem" @click="startSelect(menuOpenId)">
انتخاب
</button>
<button type="button" role="menuitem" @click="onOpenDeal(menuOpenId)">
مشاهده معامله
</button>
@@ -701,18 +772,16 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
</UiModal>
<UiModal
:open="Boolean(deleteDealId)"
title="حذف معامله"
@close="deleteDealId = null"
:open="deleteDealIds.length > 0"
:title="deleteDealIds.length > 1 ? 'حذف معامله‌ها' : 'حذف معامله'"
@close="deleteDealIds = []"
>
<p class="delete-copy">
معاملهٔ «{{ deleteDealTitle }}» حذف شود؟ این کار قابل بازگشت نیست.
</p>
<p class="delete-copy">{{ deleteConfirmCopy }}</p>
<template #footer>
<UiButton type="button" :disabled="dealDeleting" @click="confirmDeleteDeal">
{{ dealDeleting ? 'در حال حذف…' : 'حذف' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="deleteDealId = null">
<UiButton variant="ghost" type="button" @click="deleteDealIds = []">
انصراف
</UiButton>
</template>
@@ -778,6 +847,43 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
color: var(--ink);
}
.select-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.65rem 1rem;
padding: 0.55rem 0.85rem;
border-radius: var(--radius);
background: color-mix(in srgb, var(--accent) 10%, #fff);
border: 1px solid color-mix(in srgb, var(--accent) 25%, var(--line));
font-size: 0.86rem;
font-weight: 700;
color: var(--ink);
}
.select-bar span {
flex: 1;
min-width: 8rem;
}
.select-bar button {
border: 0;
background: none;
padding: 0;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
}
.select-bar .select-del {
color: var(--danger);
}
.select-bar .select-cancel {
color: var(--accent);
}
.menu-pop {
position: fixed;
inset-inline-start: 1rem;