feat: group activities into boards with their own stages, like sales pipelines

Existing stages and activities move into a default board per environment; new activities pick a board in the form.
This commit is contained in:
2026-10-20 17:23:27 +03:30
parent 9127fa334b
commit e6aa4c64f0
11 changed files with 769 additions and 58 deletions
@@ -380,7 +380,7 @@ async function onAddStage(name: string) {
if (stageBusy.value) return;
stageBusy.value = true;
try {
await api.post('/activities/stages', { name, kind: 'open' });
await api.post('/activities/stages', { name, kind: 'open', boardId: props.params.boardId });
emit('stagesChanged');
} catch (e) {
emit('error', apiMessage(e, 'افزودن ستون ناموفق بود.'));
@@ -20,6 +20,9 @@ import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{
open: boolean;
stages: ActivityStageDto[];
/** ستون‌های جدید در همین گروه ساخته می‌شوند */
boardId?: string;
boardName?: string;
}>();
const emit = defineEmits<{
@@ -34,7 +37,7 @@ 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>>({
const createForm = reactive<Required<Omit<CreateActivityStageRequest, 'boardId'>>>({
name: '',
color: 'blue',
kind: 'open',
@@ -140,7 +143,11 @@ async function create() {
busyId.value = 'create';
error.value = '';
try {
await api.post('/activities/stages', { ...createForm, name: createForm.name.trim() });
await api.post('/activities/stages', {
...createForm,
name: createForm.name.trim(),
boardId: props.boardId,
});
createForm.name = '';
emit('changed');
} catch (e) {
@@ -152,7 +159,11 @@ async function create() {
</script>
<template>
<UiModal :open="open" title="مدیریت ستون‌ها و تب‌های فعالیت" @close="emit('close')">
<UiModal
:open="open"
:title="boardName ? `مدیریت ستون‌های گروه «${boardName}»` : 'مدیریت ستون‌ها و تب‌های فعالیت'"
@close="emit('close')"
>
<div class="manager">
<p class="hint">
هر ستون یک «نوع» دارد: فعالیتی که به ستون <b>انجام‌شده</b> کشیده شود خودکار انجام‌شده ثبت
+237 -6
View File
@@ -14,9 +14,11 @@ import {
UiBusyOverlay,
UiButton,
UiDropdownMenu,
UiInput,
UiModal,
UiPageHeader,
UiPagination,
UiSelect,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import UiSegmented from '@/components/ui/UiSegmented.vue';
@@ -39,6 +41,7 @@ const activityClientReadOptions = {
};
import ActivityFormView from '@/views/ActivityFormView.vue';
import type {
ActivityBoardDto,
ActivityDto,
ActivityFilter,
ActivityStageCountsDto,
@@ -158,6 +161,19 @@ const stages = ref<ActivityStageDto[]>([]);
const stagesManagerOpen = ref(false);
const typesManagerOpen = ref(false);
/** گروه‌های فعالیت (مثل مسیر فروش)؛ هر گروه ستون‌های خودش را دارد */
const boards = ref<ActivityBoardDto[]>([]);
const defaultBoardId = computed(() => boards.value.find((b) => b.isDefault)?.id ?? '');
const board = computed<string>(() => {
const value = typeof route.query.board === 'string' ? route.query.board : '';
if (value && boards.value.some((b) => b.id === value)) return value;
return defaultBoardId.value;
});
const currentBoard = computed(() => boards.value.find((b) => b.id === board.value) ?? null);
const boardModal = ref<{ mode: 'create' | 'edit'; name: string } | null>(null);
const boardSaving = ref(false);
const boardDeleteOpen = ref(false);
async function onTypesChanged() {
await activityTypes.load(true);
kanbanReload.value += 1;
@@ -276,6 +292,7 @@ const filterLabel = computed(() => {
/** فیلترهای مشترک لیست، شمارش‌ها و کانبان */
const sharedParams = computed<Record<string, string | undefined>>(() => ({
boardId: board.value || 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,
@@ -361,15 +378,96 @@ function subtitle(item: ActivityDto) {
return (item.description ?? '').split('\n')[0].trim();
}
async function loadBoards() {
try {
const { data: rows } = await api.get<ActivityBoardDto[]>('/activities/boards');
boards.value = rows;
} catch {
error.value = 'بارگذاری گروه‌های فعالیت ناموفق بود.';
}
}
async function loadStages() {
try {
const { data: rows } = await api.get<ActivityStageDto[]>('/activities/stages');
const { data: rows } = await api.get<ActivityStageDto[]>('/activities/stages', {
params: { boardId: board.value || undefined },
});
stages.value = rows;
} catch {
error.value = 'بارگذاری ستون‌های فعالیت ناموفق بود.';
}
}
function setBoard(next: string) {
void router.replace({
name: 'activities',
query: {
...route.query,
board: next && next !== defaultBoardId.value ? next : undefined,
stage: undefined,
page: undefined,
},
});
}
const boardModel = computed<string>({
get: () => board.value,
set: (v) => setBoard(v),
});
function openCreateBoard() {
boardModal.value = { mode: 'create', name: '' };
}
function openEditBoard() {
if (!currentBoard.value) return;
boardModal.value = { mode: 'edit', name: currentBoard.value.name };
}
async function saveBoard() {
const modal = boardModal.value;
const name = modal?.name.trim() ?? '';
if (!modal || name.length < 2 || boardSaving.value) return;
boardSaving.value = true;
error.value = '';
try {
if (modal.mode === 'create') {
const { data: created } = await api.post<ActivityBoardDto>('/activities/boards', { name });
await loadBoards();
boardModal.value = null;
setBoard(created.id);
} else if (currentBoard.value) {
await api.patch(`/activities/boards/${currentBoard.value.id}`, { name });
await loadBoards();
boardModal.value = null;
}
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ذخیرهٔ گروه ناموفق بود.')
: 'ذخیرهٔ گروه ناموفق بود.';
} finally {
boardSaving.value = false;
}
}
async function deleteBoard() {
const target = currentBoard.value;
if (!target || target.isDefault || boardSaving.value) return;
boardSaving.value = true;
error.value = '';
try {
await api.delete(`/activities/boards/${target.id}`);
boardDeleteOpen.value = false;
boardModal.value = null;
await loadBoards();
setBoard(defaultBoardId.value);
} catch {
error.value = 'حذف گروه ناموفق بود.';
} finally {
boardSaving.value = false;
}
}
/** شمارش تب‌های زمانی (با ستون انتخابی در نمای تب) و شمارش ستون‌ها (با زمان انتخابی) */
async function loadCounts() {
const base = sharedParams.value;
@@ -775,6 +873,17 @@ watch(
},
);
/** گروه عوض شد → ستون‌های همان گروه، بعد لیست و شمارش‌ها */
watch(board, (next, prev) => {
if (!prev || next === prev) return;
clearSelect();
void loadStages().then(() => {
kanbanReload.value += 1;
void load();
void loadCounts();
});
});
/** لینک‌های قدیمی ?filter=done → ستون انجام‌شده */
function migrateLegacyDoneFilter() {
if (route.query.filter !== 'done') return false;
@@ -801,11 +910,13 @@ onMounted(() => {
mobileQuery.addEventListener('change', syncMobile);
void loadUsers();
void activityTypes.load(true);
void loadStages().then(() => {
if (migrateLegacyDoneFilter()) return;
void load();
void loadCounts();
});
void loadBoards()
.then(() => loadStages())
.then(() => {
if (migrateLegacyDoneFilter()) return;
void load();
void loadCounts();
});
});
watch(viewMode, () => {
@@ -887,6 +998,32 @@ onUnmounted(() => {
</button>
</div>
<div class="view-row-end">
<div v-if="boards.length" class="board-picker" @click.stop>
<UiSelect v-model="boardModel" size="sm" aria-label="گروه فعالیت">
<option v-for="b in boards" :key="b.id" :value="b.id">{{ b.name }}</option>
</UiSelect>
<UiButton
v-if="canManageStages"
variant="ghost"
size="sm"
type="button"
aria-label="ویرایش گروه"
title="ویرایش گروه"
@click="openEditBoard"
>
<UiIcon name="pencil" :size="14" />
</UiButton>
<UiButton
v-if="canManageStages"
variant="ghost"
size="sm"
type="button"
@click="openCreateBoard"
>
<UiIcon name="plus" :size="14" />
گروه جدید
</UiButton>
</div>
<UiButton
v-if="canManageStages"
variant="ghost"
@@ -1428,6 +1565,7 @@ onUnmounted(() => {
:initial-type="activityModal.type"
:initial-title="activityModal.title"
:initial-stage-id="activityModal.stageId"
:initial-board-id="board || undefined"
@saved="onActivitySaved"
@cancel="activityModal = null"
/>
@@ -1451,9 +1589,68 @@ onUnmounted(() => {
v-if="canManageStages"
:open="stagesManagerOpen"
:stages="stages"
:board-id="board || undefined"
:board-name="boards.length > 1 ? currentBoard?.name : undefined"
@close="stagesManagerOpen = false"
@changed="onStagesChanged(); kanbanReload += 1"
/>
<UiModal
:open="Boolean(boardModal)"
:title="boardModal?.mode === 'create' ? 'گروه فعالیت جدید' : 'ویرایش گروه فعالیت'"
@close="boardModal = null"
>
<form v-if="boardModal" class="board-form" @submit.prevent="saveBoard">
<label class="board-form-label" for="activity-board-name">نام گروه</label>
<UiInput
id="activity-board-name"
v-model="boardModal.name"
:maxlength="80"
placeholder="مثلاً پشتیبانی، نصب، پیگیری فروش…"
/>
<p v-if="boardModal.mode === 'create'" class="board-form-hint">
گروه جدید با ستون‌های پیش‌فرض ساخته می‌شود و بعد می‌توانید ستون‌هایش را تغییر دهید.
</p>
<UiButton
v-if="boardModal.mode === 'edit' && currentBoard && !currentBoard.isDefault"
variant="ghost"
size="sm"
type="button"
class="board-delete"
@click="boardDeleteOpen = true"
>
<UiIcon name="trash" :size="14" />
حذف این گروه
</UiButton>
</form>
<template #footer>
<UiButton
type="button"
:disabled="boardSaving || (boardModal?.name.trim().length ?? 0) < 2"
@click="saveBoard"
>
{{ boardSaving ? 'در حال ذخیره…' : boardModal?.mode === 'create' ? 'ساخت گروه' : 'ذخیره' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="boardModal = null">انصراف</UiButton>
</template>
</UiModal>
<UiModal
:open="boardDeleteOpen"
title="حذف گروه فعالیت"
@close="boardDeleteOpen = false"
>
<p class="delete-copy">
گروه «{{ currentBoard?.name }}» و ستون‌هایش حذف شود؟ فعالیت‌های این گروه حذف نمی‌شوند و به
گروه اصلی منتقل می‌شوند.
</p>
<template #footer>
<UiButton type="button" :disabled="boardSaving" @click="deleteBoard">
{{ boardSaving ? 'در حال حذف…' : 'حذف گروه' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="boardDeleteOpen = false">انصراف</UiButton>
</template>
</UiModal>
<ActivityTypesManager
v-if="canManageStages"
:open="typesManagerOpen"
@@ -1620,6 +1817,40 @@ h1 {
margin-inline-start: auto;
}
.board-picker {
display: flex;
align-items: center;
gap: 0.35rem;
}
.board-picker .ui-select {
width: auto;
min-width: 10rem;
}
.board-form {
display: grid;
gap: 0.5rem;
}
.board-form-label {
font-size: 0.85rem;
font-weight: 700;
color: #334155;
}
.board-form-hint {
margin: 0;
font-size: 0.8rem;
color: var(--muted);
line-height: 1.6;
}
.board-delete {
justify-self: start;
color: var(--danger);
}
.stage-dot {
display: inline-block;
width: 0.5rem;
+24 -1
View File
@@ -25,11 +25,13 @@ import {
UiInput,
UiModal,
UiPageHeader,
UiSelect,
UiTextarea,
} from '@/components/ui';
import ActivityFieldsEditor from '@/components/activities/ActivityFieldsEditor.vue';
import { typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import type {
ActivityBoardDto,
ActivityDto,
ActivityFieldValue,
ActivityFieldValues,
@@ -52,6 +54,8 @@ const props = withDefaults(
initialType?: ActivityType;
initialTitle?: string;
initialStageId?: string;
/** گروه فعالیت پیش‌فرض برای فعالیت جدید (گروهی که کاربر در آن است) */
initialBoardId?: string;
}>(),
{ embedded: false },
);
@@ -74,6 +78,9 @@ const error = ref('');
const people = ref<PersonDto[]>([]);
const deals = ref<DealDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const boards = ref<ActivityBoardDto[]>([]);
const boardId = ref('');
const defaultBoardId = computed(() => boards.value.find((b) => b.isDefault)?.id ?? '');
const activityTypes = useActivityTypes();
const title = ref('');
@@ -326,19 +333,25 @@ async function load() {
loading.value = true;
error.value = '';
try {
const [peopleRes, boardRes, usersRes] = await Promise.all([
const [peopleRes, boardRes, usersRes, activityBoardsRes] = await Promise.all([
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
api.get<PipelineBoardDto>('/pipelines/board'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
api
.get<ActivityBoardDto[]>('/activities/boards')
.catch(() => ({ data: [] as ActivityBoardDto[] })),
activityTypes.load(true),
]);
people.value = peopleRes.data.items;
deals.value = boardRes.data.stages.flatMap((s) => s.deals);
users.value = usersRes.data;
boards.value = activityBoardsRes.data;
boardId.value = props.initialBoardId || defaultBoardId.value;
if (!isNew.value && id.value) {
const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
title.value = data.title;
boardId.value = data.boardId || defaultBoardId.value;
type.value = data.type;
typeDefId.value =
data.typeDef?.id ?? activityTypes.systemOf.value[data.type]?.id ?? '';
@@ -421,6 +434,7 @@ function payload(): CreateActivityRequest {
const keys = new Set(typeFields.value.map((f) => f.key));
return {
title: title.value,
boardId: boardId.value || null,
type: type.value || 'task',
typeDefId: typeDefId.value || null,
fields: Object.fromEntries(
@@ -631,6 +645,15 @@ watch([id, isNew], () => void load(), { immediate: true });
</div>
</div>
<div v-if="boards.length > 1" class="card">
<div class="field">
<label for="activity-board">گروه فعالیت</label>
<UiSelect id="activity-board" v-model="boardId">
<option v-for="b in boards" :key="b.id" :value="b.id">{{ b.name }}</option>
</UiSelect>
</div>
</div>
<div v-if="typeFields.length" class="card type-fields">
<p class="type-fields-title">
<span class="type-ico sm" :style="typeColorStyle(typeMeta!.color)">