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;