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:
@@ -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> کشیده شود خودکار انجامشده ثبت
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)">
|
||||
|
||||
Reference in New Issue
Block a user