feat(activities): editable stages with tab and kanban views

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 11:18:53 +03:30
co-authored by Cursor
parent b4ffef9b6f
commit aa2f39256a
15 changed files with 1860 additions and 88 deletions
@@ -0,0 +1,430 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import type {
ActivityDto,
ActivityStageDto,
ActivityType,
PaginatedResponse,
} from '@mento/shared';
import { api } from '@/lib/api';
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
import {
ACTIVITY_BOARD_COPY,
initialsFromTitle,
moveStageToIndex,
type PipelineAccent,
type PipelineDeal,
type PipelineStage,
} from '@/lib/pipeline-board';
const PAGE = 30;
const props = defineProps<{
stages: ActivityStageDto[];
/** فیلترهای مشترک با حالت تب (filter زمانی، q، desk، type، assigneeId) */
params: Record<string, string | undefined>;
canManage: boolean;
reloadKey: number;
}>();
const emit = defineEmits<{
changed: [];
stagesChanged: [];
add: [stageId: string];
error: [message: string];
}>();
const router = useRouter();
type Column = {
items: ActivityDto[];
total: number;
page: number;
loading: boolean;
};
const columns = ref<Record<string, Column>>({});
const initialLoading = ref(false);
const draggingId = ref<string | null>(null);
const dropStageId = ref<string | null>(null);
const draggingColumnId = ref<string | null>(null);
const overlayDeal = ref<PipelineDeal | null>(null);
const overlayAccent = ref<PipelineAccent>('green');
const overlayPos = ref({ x: 0, y: 0 });
const moving = ref(false);
const stageBusy = ref(false);
const localOrder = ref<string[] | null>(null);
const typeLabel: Record<ActivityType, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'وظیفه',
note: 'پیام',
};
const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = {
open: undefined,
done: 'انجام‌شده',
canceled: 'لغو',
};
const orderedStages = computed(() => {
if (!localOrder.value) return props.stages;
const byId = new Map(props.stages.map((s) => [s.id, s]));
return localOrder.value.map((id) => byId.get(id)).filter(Boolean) as ActivityStageDto[];
});
function apiMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
if (Array.isArray(msg)) return msg[0] ?? fallback;
if (typeof msg === 'string' && msg) return msg;
}
return fallback;
}
function dateText(item: ActivityDto) {
if (!item.dueAt) return 'بدون موعد';
const due = new Date(item.dueAt);
const days = calendarDaysBetween(due, new Date());
const day =
days === 0 ? 'امروز' : days === 1 ? 'دیروز' : days === -1 ? 'فردا' : formatJalaliDay(due);
const late =
item.status === 'open' &&
startOfLocalDay(due).getTime() < startOfLocalDay().getTime();
return `${late ? 'عقب‌افتاده · ' : ''}${day} · ${formatClockFa(due)}`;
}
function toCard(item: ActivityDto, stageId: string): PipelineDeal {
const party = item.company?.name || item.person?.fullName || item.deal?.title || '';
const assigneeNames = item.assignees?.length
? item.assignees.map((a) => a.fullName)
: item.assignee
? [item.assignee.fullName]
: [];
return {
id: item.id,
title: item.title,
description: [typeLabel[item.type], party].filter(Boolean).join(' · '),
amount: null,
date: dateText(item),
initials: initialsFromTitle(party || item.title),
stageId,
assigneeNames,
ownerId: item.assignee?.id ?? null,
assigneeIds: (item.assignees ?? []).map((a) => a.id),
};
}
const boardStages = computed<PipelineStage[]>(() =>
orderedStages.value.map((s) => {
const col = columns.value[s.id];
const items = col?.items ?? [];
const total = col?.total ?? 0;
return {
id: s.id,
title: s.name,
color: s.color as PipelineAccent,
deals: items.map((item) => toCard(item, s.id)),
total,
hasMore: items.length < total,
loadingMore: col?.loading ?? false,
badge: KIND_BADGE[s.kind],
};
}),
);
async function fetchPage(stageId: string, page: number) {
const { data } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { ...props.params, stageId, page, limit: PAGE },
});
return data;
}
let loadSeq = 0;
async function loadAll() {
const seq = ++loadSeq;
initialLoading.value = true;
try {
const results = await Promise.all(
props.stages.map(async (s) => [s.id, await fetchPage(s.id, 1)] as const),
);
if (seq !== loadSeq) return;
const next: Record<string, Column> = {};
for (const [id, res] of results) {
next[id] = { items: res.items, total: res.total, page: 1, loading: false };
}
columns.value = next;
} catch (e) {
if (seq === loadSeq) emit('error', apiMessage(e, 'بارگذاری ستون‌های فعالیت ناموفق بود.'));
} finally {
if (seq === loadSeq) initialLoading.value = false;
}
}
async function loadMore(stageId: string) {
const col = columns.value[stageId];
if (!col || col.loading) return;
col.loading = true;
try {
const res = await fetchPage(stageId, col.page + 1);
const seen = new Set(col.items.map((i) => i.id));
col.items = [...col.items, ...res.items.filter((i) => !seen.has(i.id))];
col.total = res.total;
col.page += 1;
} catch (e) {
emit('error', apiMessage(e, 'بارگذاری بیشتر ناموفق بود.'));
} finally {
col.loading = false;
}
}
watch(
() => [
props.stages.map((s) => s.id).sort().join(','),
JSON.stringify(props.params),
props.reloadKey,
],
() => void loadAll(),
{ immediate: true },
);
watch(
() => props.stages,
() => {
localOrder.value = null;
},
);
function findStageOf(id: string) {
return Object.entries(columns.value).find(([, col]) => col.items.some((i) => i.id === id))?.[0];
}
function onDragStart(deal: PipelineDeal, event: DragEvent) {
draggingId.value = deal.id;
overlayDeal.value = deal;
const sid = findStageOf(deal.id);
overlayAccent.value =
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green';
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 };
event.dataTransfer?.setData('text/plain', deal.id);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
const img = new Image();
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0);
}
}
function onDragEnd() {
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
}
function onDragOver(stageId: string, event: DragEvent) {
event.preventDefault();
dropStageId.value = stageId;
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
if (event.clientX || event.clientY) {
overlayPos.value = { x: event.clientX + 12, y: event.clientY + 12 };
}
}
async function moveCard(activityId: string, toStageId: string, index?: number) {
const fromStageId = findStageOf(activityId);
if (!fromStageId || fromStageId === toStageId) return;
const from = columns.value[fromStageId]!;
const to = columns.value[toStageId];
if (!to) return;
const item = from.items.find((i) => i.id === activityId)!;
const toStage = props.stages.find((s) => s.id === toStageId)!;
const snapshot = {
fromItems: from.items,
fromTotal: from.total,
toItems: to.items,
toTotal: to.total,
};
const optimistic: ActivityDto = { ...item, status: toStage.kind, stageId: toStageId };
from.items = from.items.filter((i) => i.id !== activityId);
from.total = Math.max(0, from.total - 1);
const nextTo = [...to.items];
const at = index == null || index < 0 || index > nextTo.length ? 0 : index;
nextTo.splice(at, 0, optimistic);
to.items = nextTo;
to.total += 1;
moving.value = true;
try {
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
stageId: toStageId,
});
to.items = to.items.map((i) => (i.id === activityId ? data : i));
emit('changed');
} catch (e) {
from.items = snapshot.fromItems;
from.total = snapshot.fromTotal;
to.items = snapshot.toItems;
to.total = snapshot.toTotal;
emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.'));
} finally {
moving.value = false;
}
}
async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault();
const raw = event.dataTransfer?.getData('text/plain') || draggingColumnId.value || draggingId.value || '';
dropStageId.value = null;
draggingId.value = null;
overlayDeal.value = null;
if (raw.startsWith('stage:') || draggingColumnId.value) {
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value;
draggingColumnId.value = null;
if (fromId) await reorderColumn(fromId, stageId);
return;
}
if (!raw || moving.value) return;
await moveCard(raw, stageId, index);
}
async function persistOrder(ids: string[]) {
const prev = localOrder.value;
localOrder.value = ids;
stageBusy.value = true;
try {
await api.patch('/activities/stages/reorder', { stageIds: ids });
emit('stagesChanged');
} catch (e) {
localOrder.value = prev;
emit('error', apiMessage(e, 'جابه‌جایی ستون ناموفق بود.'));
} finally {
stageBusy.value = false;
}
}
async function reorderColumn(fromId: string, toId: string) {
if (!props.canManage || stageBusy.value || fromId === toId) return;
const current = boardStages.value;
const to = current.findIndex((s) => s.id === toId);
if (to < 0) return;
await persistOrder(moveStageToIndex(current, fromId, to).map((s) => s.id));
}
async function onMoveStage(stageId: string, dir: -1 | 1) {
if (stageBusy.value) return;
const current = boardStages.value;
const from = current.findIndex((s) => s.id === stageId);
const to = from + dir;
if (from < 0 || to < 0 || to >= current.length) return;
await persistOrder(moveStageToIndex(current, stageId, to).map((s) => s.id));
}
async function onRenameStage(stageId: string, name: string) {
if (stageBusy.value) return;
stageBusy.value = true;
try {
await api.patch(`/activities/stages/${stageId}`, { name });
emit('stagesChanged');
} catch (e) {
emit('error', apiMessage(e, 'تغییر نام ستون ناموفق بود.'));
} finally {
stageBusy.value = false;
}
}
async function onDeleteStage(stageId: string) {
if (stageBusy.value) return;
const stage = props.stages.find((s) => s.id === stageId);
const total = columns.value[stageId]?.total ?? 0;
const ok = window.confirm(
`ستون «${stage?.name ?? ''}» حذف شود؟${
total ? `\n${toFaDigits(total)} فعالیت این ستون به اولین ستون هم‌وضعیت منتقل می‌شوند.` : ''
}`,
);
if (!ok) return;
stageBusy.value = true;
try {
await api.delete(`/activities/stages/${stageId}`);
emit('stagesChanged');
emit('changed');
} catch (e) {
emit('error', apiMessage(e, 'حذف ستون ناموفق بود.'));
} finally {
stageBusy.value = false;
}
}
async function onAddStage(name: string) {
if (stageBusy.value) return;
stageBusy.value = true;
try {
await api.post('/activities/stages', { name, kind: 'open' });
emit('stagesChanged');
} catch (e) {
emit('error', apiMessage(e, 'افزودن ستون ناموفق بود.'));
} finally {
stageBusy.value = false;
}
}
function openActivity(id: string) {
void router.push({ name: 'activity-detail', params: { id } });
}
</script>
<template>
<div class="kanban-wrap">
<p v-if="initialLoading && !Object.keys(columns).length" class="muted">در حال بارگذاری ستون‌ها…</p>
<PipelineBoard
v-else
:stages="boardStages"
:dragging-id="draggingId"
:drop-stage-id="dropStageId"
:can-manage="canManage"
:copy="ACTIVITY_BOARD_COPY"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@drag-over="onDragOver"
@drop="onDrop"
@add-deal="emit('add', $event)"
@open-deal="openActivity"
@menu-deal="openActivity"
@rename-stage="onRenameStage"
@delete-stage="onDeleteStage"
@move-stage="onMoveStage"
@column-drag-start="(id: string) => (draggingColumnId = id)"
@column-drag-end="draggingColumnId = null; dropStageId = null"
@add-stage="onAddStage"
@load-more="loadMore"
/>
<Teleport to="body">
<DealCardOverlay
v-if="overlayDeal && draggingId"
:deal="overlayDeal"
:accent="overlayAccent"
:copy="ACTIVITY_BOARD_COPY"
:x="overlayPos.x"
:y="overlayPos.y"
/>
</Teleport>
</div>
</template>
<style scoped>
.kanban-wrap {
container-type: inline-size;
container-name: pipeline;
min-width: 0;
}
.muted {
margin: 0;
color: #64748b;
}
</style>
@@ -0,0 +1,370 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
import axios from 'axios';
import type {
ActivityStageColor,
ActivityStageDto,
ActivityStatus,
CreateActivityStageRequest,
} from '@mento/shared';
import {
ACTIVITY_STAGE_COLOR_LABELS,
ACTIVITY_STAGE_COLORS,
ACTIVITY_STAGE_KIND_LABELS,
} from '@mento/shared';
import { api } from '@/lib/api';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
import { UiButton, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{
open: boolean;
stages: ActivityStageDto[];
}>();
const emit = defineEmits<{
close: [];
changed: [];
}>();
const KINDS: ActivityStatus[] = ['open', 'done', 'canceled'];
type Draft = { name: string; color: ActivityStageColor; kind: ActivityStatus };
const drafts = ref<Record<string, Draft>>({});
const busyId = ref('');
const error = ref('');
const createForm = reactive<Required<CreateActivityStageRequest>>({
name: '',
color: 'blue',
kind: 'open',
});
watch(
() => [props.open, props.stages] as const,
() => {
drafts.value = Object.fromEntries(
props.stages.map((s) => [s.id, { name: s.name, color: s.color, kind: s.kind }]),
);
},
{ immediate: true },
);
function apiMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
if (Array.isArray(msg)) return msg[0] ?? fallback;
if (typeof msg === 'string' && msg) return msg;
}
return fallback;
}
function isDirty(stage: ActivityStageDto) {
const d = drafts.value[stage.id];
return Boolean(
d && (d.name.trim() !== stage.name || d.color !== stage.color || d.kind !== stage.kind),
);
}
async function save(stage: ActivityStageDto) {
const d = drafts.value[stage.id];
if (!d || !isDirty(stage)) return;
if (!d.name.trim()) {
error.value = 'نام ستون نمی‌تواند خالی باشد.';
return;
}
if (
d.kind !== stage.kind &&
!window.confirm(
'با تغییر نوع ستون، فعالیت‌های فعلی آن وضعیتشان را نگه می‌دارند و به اولین ستون هم‌وضعیت خودشان منتقل می‌شوند. ادامه می‌دهید؟',
)
) {
return;
}
busyId.value = stage.id;
error.value = '';
try {
await api.patch(`/activities/stages/${stage.id}`, {
name: d.name.trim(),
color: d.color,
kind: d.kind,
});
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'ذخیرهٔ ستون ناموفق بود.');
} finally {
busyId.value = '';
}
}
async function move(index: number, dir: -1 | 1) {
const to = index + dir;
if (to < 0 || to >= props.stages.length) return;
const ids = props.stages.map((s) => s.id);
const [moved] = ids.splice(index, 1);
ids.splice(to, 0, moved!);
busyId.value = 'order';
error.value = '';
try {
await api.patch('/activities/stages/reorder', { stageIds: ids });
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'جابه‌جایی ستون ناموفق بود.');
} finally {
busyId.value = '';
}
}
async function remove(stage: ActivityStageDto) {
if (
!window.confirm(
`ستون «${stage.name}» حذف شود؟ فعالیت‌های آن به اولین ستون هم‌وضعیت منتقل می‌شوند.`,
)
) {
return;
}
busyId.value = stage.id;
error.value = '';
try {
await api.delete(`/activities/stages/${stage.id}`);
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
} finally {
busyId.value = '';
}
}
async function create() {
if (!createForm.name.trim()) return;
busyId.value = 'create';
error.value = '';
try {
await api.post('/activities/stages', { ...createForm, name: createForm.name.trim() });
createForm.name = '';
emit('changed');
} catch (e) {
error.value = apiMessage(e, 'افزودن ستون ناموفق بود.');
} finally {
busyId.value = '';
}
}
</script>
<template>
<UiModal :open="open" title="مدیریت ستون‌ها و تب‌های فعالیت" @close="emit('close')">
<div class="manager">
<p class="hint">
هر ستون یک «نوع» دارد: فعالیتی که به ستون <b>انجام‌شده</b> کشیده شود خودکار انجام‌شده ثبت
می‌شود و در ستون <b>لغو شده</b> لغو می‌شود. از هر نوع حداقل یک ستون لازم است.
</p>
<p v-if="error" class="error">{{ error }}</p>
<div class="rows">
<div
v-for="(stage, index) in stages"
:key="stage.id"
class="row"
:style="{ '--dot': PIPELINE_ACCENT[drafts[stage.id]?.color ?? stage.color].solid }"
>
<span class="dot" aria-hidden="true" />
<input
v-if="drafts[stage.id]"
v-model="drafts[stage.id]!.name"
class="name"
maxlength="80"
aria-label="نام ستون"
@keydown.enter.prevent="save(stage)"
/>
<select v-if="drafts[stage.id]" v-model="drafts[stage.id]!.kind" aria-label="نوع ستون">
<option v-for="k in KINDS" :key="k" :value="k">{{ ACTIVITY_STAGE_KIND_LABELS[k] }}</option>
</select>
<select v-if="drafts[stage.id]" v-model="drafts[stage.id]!.color" aria-label="رنگ ستون">
<option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
</option>
</select>
<div class="row-actions">
<button
type="button"
class="icon"
:disabled="index === 0 || Boolean(busyId)"
title="انتقال به قبل"
aria-label="انتقال به قبل"
@click="move(index, -1)"
>
▲
</button>
<button
type="button"
class="icon"
:disabled="index === stages.length - 1 || Boolean(busyId)"
title="انتقال به بعد"
aria-label="انتقال به بعد"
@click="move(index, 1)"
>
▼
</button>
<UiButton
size="sm"
type="button"
:disabled="!isDirty(stage) || Boolean(busyId)"
:loading="busyId === stage.id"
@click="save(stage)"
>
ذخیره
</UiButton>
<button
type="button"
class="icon danger"
:disabled="Boolean(busyId)"
title="حذف ستون"
aria-label="حذف ستون"
@click="remove(stage)"
>
<UiIcon name="trash" :size="15" />
</button>
</div>
</div>
</div>
<form class="create" @submit.prevent="create">
<strong>ستون جدید</strong>
<div class="create-fields">
<input v-model="createForm.name" maxlength="80" placeholder="مثلاً: منتظر پاسخ مشتری" />
<select v-model="createForm.kind" aria-label="نوع ستون جدید">
<option v-for="k in KINDS" :key="k" :value="k">{{ ACTIVITY_STAGE_KIND_LABELS[k] }}</option>
</select>
<select v-model="createForm.color" aria-label="رنگ ستون جدید">
<option v-for="c in ACTIVITY_STAGE_COLORS" :key="c" :value="c">
{{ ACTIVITY_STAGE_COLOR_LABELS[c] }}
</option>
</select>
<UiButton
variant="primary"
type="submit"
:disabled="!createForm.name.trim() || Boolean(busyId)"
:loading="busyId === 'create'"
>
افزودن
</UiButton>
</div>
</form>
</div>
</UiModal>
</template>
<style scoped>
.manager {
display: grid;
gap: 0.9rem;
}
.hint {
margin: 0;
color: #64748b;
font-size: 0.84rem;
line-height: 1.8;
}
.error {
margin: 0;
color: #b91c1c;
}
.rows {
display: grid;
gap: 0.5rem;
}
.row {
display: grid;
grid-template-columns: 0.7rem minmax(0, 1fr) 9rem 6.5rem auto;
gap: 0.5rem;
align-items: center;
padding: 0.5rem 0.6rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.dot {
width: 0.65rem;
height: 0.65rem;
border-radius: 999px;
background: var(--dot);
}
input,
select {
min-height: var(--control-h);
padding: 0 0.6rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
font: inherit;
font-size: 0.86rem;
min-width: 0;
}
.row-actions {
display: flex;
align-items: center;
gap: 0.3rem;
}
.icon {
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
color: #475569;
font-size: 0.7rem;
cursor: pointer;
}
.icon:disabled {
opacity: 0.4;
cursor: default;
}
.icon.danger {
color: #dc2626;
}
.create {
display: grid;
gap: 0.5rem;
padding-top: 0.8rem;
border-top: 1px solid var(--line);
}
.create-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) 9rem 6.5rem auto;
gap: 0.5rem;
}
@media (max-width: 640px) {
.row {
grid-template-columns: 0.7rem minmax(0, 1fr) auto;
}
.row select {
grid-column: 2 / -1;
}
.row-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
.create-fields {
grid-template-columns: 1fr;
}
}
</style>
@@ -30,6 +30,7 @@ const emit = defineEmits<{
columnDragStart: [stageId: string, event: DragEvent];
columnDragEnd: [];
addStage: [name: string];
loadMore: [stageId: string];
}>();
const adding = ref(false);
@@ -82,6 +83,7 @@ function submitAdd() {
@move="(id, dir) => emit('moveStage', id, dir)"
@column-drag-start="(id, e) => emit('columnDragStart', id, e)"
@column-drag-end="emit('columnDragEnd')"
@load-more="emit('loadMore', $event)"
/>
<section v-if="canManage" class="add-col">
@@ -32,6 +32,7 @@ const emit = defineEmits<{
move: [stageId: string, dir: -1 | 1];
columnDragStart: [stageId: string, event: DragEvent];
columnDragEnd: [];
loadMore: [stageId: string];
}>();
const renaming = ref(false);
@@ -153,8 +154,9 @@ onBeforeUnmount(() => {
>
{{ stage.title }}
</h2>
<small v-if="stage.badge && !renaming" class="stage-badge">{{ stage.badge }}</small>
</div>
<span class="count">{{ toFaItemCount(stage.deals.length, copy.itemNoun) }}</span>
<span class="count">{{ toFaItemCount(stage.total ?? stage.deals.length, copy.itemNoun) }}</span>
<div v-if="canManage" ref="menuRoot" class="more-wrap">
<button
type="button"
@@ -225,6 +227,15 @@ onBeforeUnmount(() => {
/>
</div>
<p v-if="!stage.deals.length" class="empty">{{ copy.empty }}</p>
<button
v-if="stage.hasMore"
type="button"
class="load-more"
:disabled="stage.loadingMore"
@click="emit('loadMore', stage.id)"
>
{{ stage.loadingMore ? 'در حال بارگذاری…' : 'نمایش بیشتر' }}
</button>
</div>
<AddDealButton :accent="stage.color" :label="copy.addItem" @add="emit('addDeal', stage.id)" />
@@ -425,6 +436,38 @@ onBeforeUnmount(() => {
font-size: 0.85rem;
}
.stage-badge {
flex: 0 0 auto;
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: var(--stage-soft);
color: var(--stage-text);
font-size: 0.66rem;
font-weight: 700;
white-space: nowrap;
}
.load-more {
min-height: var(--control-h);
border: 1px dashed var(--stage-border);
border-radius: var(--radius);
background: #fff;
color: var(--stage-text);
font: inherit;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
}
.load-more:hover:not(:disabled) {
background: var(--stage-soft);
}
.load-more:disabled {
opacity: 0.6;
cursor: default;
}
@container pipeline (max-width: 720px) {
.column {
flex: 0 0 min(20rem, 86cqi);
+35 -1
View File
@@ -1,6 +1,6 @@
import { matchesSearch } from '@/lib/search';
export type PipelineAccent = 'green' | 'orange' | 'purple';
export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate';
export type PipelineDeal = {
id: string;
@@ -49,6 +49,16 @@ export const PROJECT_BOARD_COPY: PipelineUiCopy = {
hideAmount: true,
};
export const ACTIVITY_BOARD_COPY: PipelineUiCopy = {
itemNoun: 'فعالیت',
addItem: 'افزودن فعالیت',
empty: 'فعالیتی نیست',
addItemAria: 'افزودن فعالیت به این ستون',
itemMenuAria: 'گزینه‌های فعالیت',
listColTitle: 'فعالیت',
hideAmount: true,
};
export type PipelineStage = {
id: string;
title: string;
@@ -56,6 +66,12 @@ export type PipelineStage = {
deals: PipelineDeal[];
isWon?: boolean;
isLost?: boolean;
/** تعداد کل از سرور وقتی کارت‌ها صفحه‌بندی‌شده بارگذاری می‌شوند */
total?: number;
hasMore?: boolean;
loadingMore?: boolean;
/** برچسب کوچک کنار عنوان ستون (مثلاً نوع مرحله) */
badge?: string;
};
export const PIPELINE_ACCENT: Record<
@@ -80,6 +96,24 @@ export const PIPELINE_ACCENT: Record<
text: '#6d28d9',
border: '#ddd6fe',
},
blue: {
soft: '#eff6ff',
solid: '#3b82f6',
text: '#1d4ed8',
border: '#bfdbfe',
},
rose: {
soft: '#fff1f2',
solid: '#f43f5e',
text: '#be123c',
border: '#fecdd3',
},
slate: {
soft: '#f8fafc',
solid: '#64748b',
text: '#334155',
border: '#e2e8f0',
},
};
const ACCENT_CYCLE: PipelineAccent[] = ['green', 'orange', 'purple'];
+360 -65
View File
@@ -19,7 +19,11 @@ import {
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';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
import {
ACTIVITY_EXCEL_ALIASES,
ACTIVITY_EXCEL_FIELDS,
@@ -35,6 +39,8 @@ import ActivityFormView from '@/views/ActivityFormView.vue';
import type {
ActivityDto,
ActivityFilter,
ActivityStageCountsDto,
ActivityStageDto,
ActivityStatus,
ActivityType,
ImportContactsResult,
@@ -43,7 +49,7 @@ import type {
} from '@mento/shared';
import { brand } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canViewAllRecords } from '@/lib/roles';
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
@@ -78,14 +84,22 @@ type TypeFilter = 'all' | ActivityType;
type StatusFilter = 'all' | ActivityStatus;
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
type DeskScope = 'mine' | 'team';
type ViewMode = 'tabs' | 'board';
type TimeFilter = Exclude<ActivityFilter, 'done'>;
const TABS: Array<{ key: ActivityFilter; label: string }> = [
const TIME_TABS: Array<{ key: TimeFilter; label: string }> = [
{ key: 'today', label: 'امروز' },
{ key: 'overdue', label: 'عقب‌افتاده' },
{ key: 'upcoming', label: 'بعداً' },
{ key: 'done', label: 'انجام‌شده' },
{ key: 'all', label: 'همه' },
{ 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 }> = [
{ key: 'mine', label: 'میز من' },
@@ -113,13 +127,41 @@ const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const showDeskTabs = computed(() => canViewAllRecords(auth.user?.role));
const canManageStages = computed(() => canManageSettings(auth.user?.role));
const filter = computed<ActivityFilter>(() => {
const value = typeof route.query.filter === 'string' ? route.query.filter : 'today';
if (['today', 'overdue', 'upcoming', 'done', 'all'].includes(value)) {
return value as ActivityFilter;
function storedViewMode(): ViewMode {
try {
return localStorage.getItem(VIEW_STORAGE_KEY) === 'board' ? 'board' : 'tabs';
} catch {
return 'tabs';
}
return 'today';
}
const viewMode = computed<ViewMode>(() => {
if (route.query.view === 'board') return 'board';
if (route.query.view === 'tabs') return 'tabs';
return storedViewMode();
});
/** پیش‌فرض زمانی: در نمای تب «امروز»، در نمای ستونی «همه زمان‌ها» */
const defaultTime = computed<TimeFilter>(() => (viewMode.value === 'board' ? 'all' : 'today'));
const filter = computed<TimeFilter>(() => {
const value = typeof route.query.filter === 'string' ? route.query.filter : '';
if (TIME_KEYS.includes(value as TimeFilter)) return value as TimeFilter;
if (value === 'done') return 'all';
return defaultTime.value;
});
const stages = ref<ActivityStageDto[]>([]);
const stagesManagerOpen = ref(false);
const kanbanReload = ref(0);
const stageCounts = ref<ActivityStageCountsDto>({ counts: {}, total: 0 });
const stage = computed<string>(() => {
const value = typeof route.query.stage === 'string' ? route.query.stage : 'all';
if (value === 'all' || stages.value.some((s) => s.id === value)) return value;
return 'all';
});
const desk = computed<DeskScope>(() => {
@@ -161,17 +203,17 @@ const assigneeFilter = computed(() =>
const activityModal = ref<{
type?: ActivityType;
title?: string;
stageId?: string;
} | null>(null);
const menuId = ref<string | null>(null);
const busy = ref(false);
const pageBusy = ref(false);
const busyMessage = ref('لطفاً صبر کنید…');
const importMsg = ref('');
const counts = ref<Record<ActivityFilter, number>>({
const counts = ref<Record<TimeFilter, number>>({
today: 0,
overdue: 0,
upcoming: 0,
done: 0,
all: 0,
});
@@ -209,7 +251,26 @@ const items = computed(() => data.value?.items ?? []);
const total = computed(() => data.value?.total ?? 0);
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
const filterLabel = computed(() => TABS.find((t) => t.key === filter.value)?.label ?? 'امروز');
const stageById = computed(() => new Map(stages.value.map((s) => [s.id, s])));
const filterLabel = computed(() => {
const time = TIME_TABS.find((t) => t.key === filter.value)?.label ?? 'امروز';
const st = stage.value === 'all' ? 'همه ستون‌ها' : stageById.value.get(stage.value)?.name ?? '';
return `${st} · ${time}`;
});
/** فیلترهای مشترک لیست، شمارش‌ها و کانبان */
const sharedParams = computed<Record<string, string | undefined>>(() => ({
filter: filter.value,
q: typeof route.query.q === 'string' && route.query.q.trim() ? route.query.q.trim() : undefined,
desk: showDeskTabs.value ? desk.value : undefined,
type: typeFilter.value === 'all' ? undefined : typeFilter.value,
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
assigneeId: assigneeFilter.value || undefined,
}));
function stageDotStyle(s: ActivityStageDto) {
return { background: PIPELINE_ACCENT[s.color].solid };
}
const activeFacetCount = computed(() => {
let n = 0;
if (typeFilter.value !== 'all') n += 1;
@@ -220,7 +281,14 @@ const activeFacetCount = computed(() => {
const filterBtnLabel = computed(() =>
activeFacetCount.value ? `فیلتر (${toFaDigits(activeFacetCount.value)})` : 'فیلتر',
);
const trulyEmpty = computed(() => !loading.value && counts.value.all === 0 && !q.value.trim());
const trulyEmpty = computed(
() =>
!loading.value &&
counts.value.all === 0 &&
stage.value === 'all' &&
!q.value.trim() &&
!activeFacetCount.value,
);
const listEmpty = computed(() => !loading.value && !items.value.length);
function isOverdue(item: ActivityDto) {
@@ -269,21 +337,43 @@ function subtitle(item: ActivityDto) {
return item.deal?.title || item.description || '';
}
async function loadStages() {
try {
const { data: rows } = await api.get<ActivityStageDto[]>('/activities/stages');
stages.value = rows;
} catch {
error.value = 'بارگذاری ستون‌های فعالیت ناموفق بود.';
}
}
/** شمارش تب‌های زمانی (با ستون انتخابی در نمای تب) و شمارش ستون‌ها (با زمان انتخابی) */
async function loadCounts() {
const keys: ActivityFilter[] = ['today', 'overdue', 'upcoming', 'done', 'all'];
const deskParam = showDeskTabs.value ? desk.value : undefined;
const results = await Promise.all(
keys.map((key) =>
api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { filter: key, limit: 1, desk: deskParam },
}),
),
);
const next = { ...counts.value };
keys.forEach((key, i) => {
next[key] = results[i]?.data.total ?? 0;
});
counts.value = next;
const base = sharedParams.value;
const stageParam = viewMode.value === 'tabs' ? stage.value : 'all';
try {
const [timeResults, stageRes] = await Promise.all([
Promise.all(
TIME_KEYS.map((key) =>
api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { ...base, filter: key, stageId: stageParam, limit: 1 },
}),
),
),
api.get<ActivityStageCountsDto>('/activities/stage-counts', { params: base }),
]);
const next = { ...counts.value };
TIME_KEYS.forEach((key, i) => {
next[key] = timeResults[i]?.data.total ?? 0;
});
counts.value = next;
stageCounts.value = stageRes.data;
} catch {
/* شمارش‌ها اختیاری‌اند؛ خطای اصلی را load نشان می‌دهد */
}
}
async function refreshAll() {
await Promise.all([viewMode.value === 'tabs' ? load() : Promise.resolve(), loadCounts()]);
}
async function loadUsers() {
@@ -300,20 +390,17 @@ async function loadUsers() {
}
async function load() {
if (viewMode.value !== 'tabs') return;
loading.value = true;
error.value = '';
menuId.value = null;
try {
const { data: res } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: {
filter: filter.value,
q: q.value || undefined,
...sharedParams.value,
stageId: stage.value,
page: page.value,
limit: pageSize.value,
desk: showDeskTabs.value ? desk.value : undefined,
type: typeFilter.value === 'all' ? undefined : typeFilter.value,
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
assigneeId: assigneeFilter.value || undefined,
},
});
data.value = res;
@@ -339,18 +426,65 @@ function setDesk(next: DeskScope) {
});
}
function setFilter(next: ActivityFilter) {
function setFilter(next: TimeFilter) {
filterOpen.value = false;
void router.replace({
name: 'activities',
query: {
...route.query,
filter: next === 'today' ? undefined : next,
filter: next === defaultTime.value ? undefined : next,
page: undefined,
},
});
}
function setStage(next: string) {
void router.replace({
name: 'activities',
query: {
...route.query,
stage: next === 'all' ? undefined : next,
page: undefined,
},
});
}
function setViewMode(next: ViewMode) {
try {
localStorage.setItem(VIEW_STORAGE_KEY, next);
} catch {
/* ignore */
}
void router.replace({
name: 'activities',
query: {
...route.query,
view: next,
filter: undefined,
page: undefined,
},
});
}
const viewModel = computed<ViewMode>({
get: () => viewMode.value,
set: (v) => setViewMode(v),
});
function onStagesChanged() {
void loadStages().then(() => loadCounts());
}
function openAddInStage(stageId: string) {
activityModal.value = { stageId };
}
function onActivitySaved() {
activityModal.value = null;
kanbanReload.value += 1;
void refreshAll();
}
function setPage(next: number) {
const safe = Math.min(Math.max(1, next), pageCount.value);
void router.replace({
@@ -394,17 +528,7 @@ function setTypeFilter(next: TypeFilter) {
}
function setStatusFilter(next: StatusFilter) {
// Avoid empty results when a time-tab forces a different status.
const nextFilter =
next === 'all'
? route.query.filter
: next === 'done'
? 'done'
: 'all';
patchFacetQuery({
status: next === 'all' ? undefined : next,
filter: nextFilter === 'today' || !nextFilter ? undefined : String(nextFilter),
});
patchFacetQuery({ status: next === 'all' ? undefined : next });
}
function setAssigneeFilter(next: string) {
@@ -426,7 +550,7 @@ async function complete(id: string) {
menuId.value = null;
try {
await api.patch(`/activities/${id}/complete`);
await Promise.all([load(), loadCounts()]);
await refreshAll();
} catch {
error.value = 'ثبت انجام‌شدن ناموفق بود.';
}
@@ -476,7 +600,8 @@ async function onImportDone(data: ImportContactsResult) {
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
}
await Promise.all([load(), loadCounts()]);
kanbanReload.value += 1;
await refreshAll();
}
function onImportFail(message: string) {
@@ -497,6 +622,8 @@ watch(
route.query.type,
route.query.status,
route.query.assigneeId,
route.query.stage,
route.query.view,
],
() => {
q.value = typeof route.query.q === 'string' ? route.query.q : '';
@@ -505,6 +632,21 @@ watch(
},
);
/** لینک‌های قدیمی ?filter=done → ستون انجام‌شده */
function migrateLegacyDoneFilter() {
if (route.query.filter !== 'done') return false;
const doneStage = stages.value.find((s) => s.kind === 'done');
void router.replace({
name: 'activities',
query: {
...route.query,
filter: 'all',
stage: doneStage?.id,
},
});
return true;
}
let mobileQuery: MediaQueryList | null = null;
function syncMobile() {
isMobile.value = mobileQuery?.matches ?? false;
@@ -515,8 +657,11 @@ onMounted(() => {
syncMobile();
mobileQuery.addEventListener('change', syncMobile);
void loadUsers();
void load();
void loadCounts();
void loadStages().then(() => {
if (migrateLegacyDoneFilter()) return;
void load();
void loadCounts();
});
});
onUnmounted(() => {
@@ -575,32 +720,77 @@ onUnmounted(() => {
</template>
</UiPageHeader>
<div v-if="showDeskTabs" class="desk-tabs" role="tablist" aria-label="محدوده میز کار">
<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>
</div>
<div class="view-row-end">
<UiButton
v-if="canManageStages"
variant="ghost"
size="sm"
type="button"
@click="stagesManagerOpen = true"
>
<UiIcon name="settings" :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="ستون‌های فعالیت">
<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)"
:class="{ on: stage === 'all' }"
:aria-selected="stage === 'all'"
@click="setStage('all')"
>
{{ tab.label }}
<span>همه</span>
<em>{{ toFaDigits(stageCounts.total.toLocaleString('en-US')) }}</em>
</button>
<button
v-for="s in stages"
:key="s.id"
type="button"
role="tab"
:class="{ on: stage === s.id }"
:aria-selected="stage === s.id"
@click="setStage(s.id)"
>
<i class="stage-dot" :style="stageDotStyle(s)" aria-hidden="true" />
<span>{{ s.name }}</span>
<em>{{ toFaDigits((stageCounts.counts[s.id] ?? 0).toLocaleString('en-US')) }}</em>
</button>
</div>
<div class="tabs" role="tablist" aria-label="فیلتر فعالیت">
<div class="time-chips" role="tablist" aria-label="فیلتر زمانی">
<button
v-for="tab in TABS"
v-for="tab in TIME_TABS"
:key="tab.key"
type="button"
role="tab"
:class="{ on: filter === tab.key }"
:class="{ on: filter === tab.key, late: tab.key === 'overdue' }"
:aria-selected="filter === tab.key"
@click="setFilter(tab.key)"
>
<span>{{ tab.label }}</span>
<em>{{ toFaDigits(counts[tab.key]) }}</em>
{{ tab.label }}
<em>{{ toFaDigits(counts[tab.key].toLocaleString('en-US')) }}</em>
</button>
</div>
@@ -681,7 +871,20 @@ onUnmounted(() => {
<p v-if="error" class="error">{{ error }}</p>
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<ActivitiesKanban
v-if="viewMode === 'board'"
:stages="stages"
:params="sharedParams"
:can-manage="canManageStages"
:reload-key="kanbanReload"
@changed="loadCounts"
@stages-changed="onStagesChanged"
@add="openAddInStage"
@error="(msg: string) => (error = msg)"
/>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else-if="listEmpty" class="empty-card">
<div class="art" aria-hidden="true">
@@ -780,11 +983,15 @@ onUnmounted(() => {
{{ item.company?.name || item.person?.fullName }}
</span>
</div>
<div v-if="colOn('status')">
<div v-if="colOn('status')" class="status-cell">
<span class="status" :class="uiStatus(item)">
<UiIcon :name="statusIcon[uiStatus(item)]" :size="13" />
{{ statusLabel[uiStatus(item)] }}
</span>
<small v-if="item.stageId && stageById.get(item.stageId)" class="stage-name">
<i class="stage-dot" :style="stageDotStyle(stageById.get(item.stageId)!)" aria-hidden="true" />
{{ stageById.get(item.stageId)!.name }}
</small>
</div>
<div v-if="!isMobile" class="row-more" @click.stop>
<UiDropdownMenu
@@ -901,10 +1108,19 @@ onUnmounted(() => {
embedded
:initial-type="activityModal.type"
:initial-title="activityModal.title"
@saved="activityModal = null; void load(); void loadCounts()"
:initial-stage-id="activityModal.stageId"
@saved="onActivitySaved"
@cancel="activityModal = null"
/>
</UiModal>
<ActivityStagesManager
v-if="canManageStages"
:open="stagesManagerOpen"
:stages="stages"
@close="stagesManagerOpen = false"
@changed="onStagesChanged(); kanbanReload += 1"
/>
</section>
</template>
@@ -1049,6 +1265,85 @@ h1 {
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
.view-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
flex-wrap: wrap;
}
.view-row-end {
display: flex;
align-items: center;
gap: 0.5rem;
margin-inline-start: auto;
}
.stage-dot {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
flex: 0 0 auto;
}
.time-chips {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.time-chips button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--line);
background: #fff;
color: #475569;
font: inherit;
font-size: 0.82rem;
font-weight: 700;
padding: 0.32rem 0.75rem;
border-radius: 999px;
cursor: pointer;
}
.time-chips button em {
font-style: normal;
font-size: 0.7rem;
color: #64748b;
}
.time-chips button.late em {
color: #dc2626;
}
.time-chips button.on {
border-color: #5eead4;
background: #f0fdfa;
color: #0f766e;
}
.status-cell {
display: grid;
gap: 0.25rem;
justify-items: start;
min-width: 0;
}
.stage-name {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: #64748b;
font-size: 0.72rem;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tabs {
display: flex;
gap: 0.2rem;
+5 -1
View File
@@ -46,6 +46,7 @@ const props = withDefaults(
initialPersonId?: string;
initialType?: ActivityType;
initialTitle?: string;
initialStageId?: string;
}>(),
{ embedded: false },
);
@@ -438,7 +439,10 @@ async function submit(next: 'detail' | 'new' = 'detail') {
try {
let savedId = id.value;
if (isNew.value) {
const { data } = await api.post<ActivityDto>('/activities', payload());
const { data } = await api.post<ActivityDto>('/activities', {
...payload(),
...(props.initialStageId ? { stageId: props.initialStageId } : {}),
});
savedId = data.id;
if (isDone.value) {
await api.patch(`/activities/${savedId}/complete`);