feat(activities): editable stages with tab and kanban views
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user