feat(deals): plan activities on the deal and create them from the board

Show related activities on deal detail, prefill dealId on activity create, and add a side-panel quick create from pipeline cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-11 15:06:20 +03:30
co-authored by Cursor
parent 4f08ffae36
commit ec9a7cf9c9
6 changed files with 845 additions and 32 deletions
@@ -0,0 +1,393 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
Alert as AAlert,
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Collapse as ACollapse,
CollapsePanel as ACollapsePanel,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Space as ASpace,
Spin as ASpin,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import {
CalendarOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
LeftOutlined,
PhoneOutlined,
PlusOutlined,
ScheduleOutlined,
TeamOutlined,
WarningOutlined,
} from '@ant-design/icons-vue';
import type { ActivityDto, ActivityStatus, ActivityType, PaginatedResponse } from '@mento/shared';
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
import { api } from '@/lib/api';
import {
calendarDaysBetween,
formatClockFa,
formatJalaliDay,
startOfLocalDay,
toFaDigits,
} from '@/lib/jalali';
const { Text: ATypographyText, Title: ATypographyTitle, Paragraph: ATypographyParagraph } =
Typography;
const { token } = theme.useToken();
const STATUS_LABELS: Record<ActivityStatus, string> = {
open: 'باز',
done: 'انجام‌شده',
canceled: 'لغو شده',
};
const TYPE_ICONS: Record<ActivityType, typeof PhoneOutlined> = {
call: PhoneOutlined,
meeting: TeamOutlined,
task: CheckCircleOutlined,
note: ScheduleOutlined,
};
type DueTone = 'overdue' | 'today' | 'soon' | 'later' | 'none';
const props = withDefaults(
defineProps<{
dealId: string;
personId?: string | null;
companyId?: string | null;
dealTitle?: string;
}>(),
{
personId: null,
companyId: null,
dealTitle: '',
},
);
const router = useRouter();
const items = ref<ActivityDto[]>([]);
const loading = ref(false);
const error = ref('');
const closedKeys = ref<string[]>([]);
function sortByDue(a: ActivityDto, b: ActivityDto) {
const ta = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY;
const tb = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY;
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
if (Number.isNaN(ta)) return 1;
if (Number.isNaN(tb)) return -1;
return ta - tb;
}
const planned = computed(() =>
items.value.filter((a) => a.status === 'open').slice().sort(sortByDue),
);
const closed = computed(() =>
items.value
.filter((a) => a.status !== 'open')
.slice()
.sort((a, b) => {
const ta = a.completedAt || a.updatedAt || a.dueAt || '';
const tb = b.completedAt || b.updatedAt || b.dueAt || '';
return tb.localeCompare(ta);
}),
);
function dueTone(dueAt: string | null): DueTone {
if (!dueAt) return 'none';
const due = new Date(dueAt);
if (Number.isNaN(due.getTime())) return 'none';
const days = calendarDaysBetween(startOfLocalDay(), startOfLocalDay(due));
if (days < 0) return 'overdue';
if (days === 0) return 'today';
if (days <= 2) return 'soon';
return 'later';
}
function dueLabel(dueAt: string | null) {
if (!dueAt) return 'بدون موعد';
const d = new Date(dueAt);
if (Number.isNaN(d.getTime())) return 'بدون موعد';
const tone = dueTone(dueAt);
if (tone === 'overdue') {
const days = Math.abs(calendarDaysBetween(startOfLocalDay(), startOfLocalDay(d)));
return days === 1 ? 'دیروز موعد داشت' : `${toFaDigits(days)} روز تأخیر`;
}
if (tone === 'today') return `امروز · ${formatClockFa(d)}`;
return `${formatJalaliDay(d)} · ${formatClockFa(d)}`;
}
function dueTagColor(tone: DueTone) {
if (tone === 'overdue') return 'error';
if (tone === 'today') return 'warning';
if (tone === 'soon') return 'processing';
return 'default';
}
function rowStyle(tone: DueTone, closedRow = false) {
const base = {
width: '100%',
padding: `${token.value.paddingSM}px ${token.value.padding}px`,
borderRadius: `${token.value.borderRadiusLG}px`,
border: `1px solid ${token.value.colorBorderSecondary}`,
background: token.value.colorBgContainer,
cursor: 'pointer' as const,
opacity: closedRow ? 0.78 : 1,
};
if (closedRow) {
return { ...base, background: token.value.colorFillAlter };
}
if (tone === 'overdue') {
return {
...base,
borderColor: token.value.colorErrorBorder,
background: token.value.colorErrorBg,
};
}
if (tone === 'today') {
return {
...base,
borderColor: token.value.colorWarningBorder,
background: token.value.colorWarningBg,
};
}
return base;
}
function avatarStyle(tone: DueTone) {
if (tone === 'overdue') {
return { backgroundColor: token.value.colorErrorBg, color: token.value.colorError };
}
if (tone === 'today') {
return { backgroundColor: token.value.colorWarningBg, color: token.value.colorWarning };
}
return {
backgroundColor: token.value.colorPrimaryBg,
color: token.value.colorPrimary,
};
}
function typeLabel(item: ActivityDto) {
const type = item.type as ActivityType;
return item.typeDef?.name || ACTIVITY_TYPE_LABELS[type];
}
function statusLabel(status: ActivityDto['status']) {
return STATUS_LABELS[status as ActivityStatus];
}
function typeIcon(type: ActivityDto['type']) {
return TYPE_ICONS[type as ActivityType];
}
function newActivityQuery() {
const query: Record<string, string> = { dealId: props.dealId };
if (props.personId) query.personId = props.personId;
if (props.companyId) query.companyId = props.companyId;
if (props.dealTitle.trim()) query.title = `پیگیری: ${props.dealTitle.trim()}`;
return query;
}
function goCreate() {
void router.push({ name: 'activity-new', query: newActivityQuery() });
}
function goDetail(id: string) {
void router.push({ name: 'activity-detail', params: { id } });
}
async function load() {
if (!props.dealId) return;
loading.value = true;
error.value = '';
try {
const { data } = await api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { filter: 'all', dealId: props.dealId, limit: 50 },
});
items.value = data.items ?? [];
} catch {
error.value = 'بارگذاری فعالیت‌های معامله ممکن نشد.';
items.value = [];
} finally {
loading.value = false;
}
}
watch(
() => props.dealId,
() => {
void load();
},
{ immediate: true },
);
</script>
<template>
<a-card :bordered="false">
<template #title>
<a-space :size="8">
<CalendarOutlined :style="{ color: token.colorPrimary }" />
<span>فعالیت‌های برنامه‌ریزی‌شده</span>
<a-tag v-if="planned.length" color="processing" :bordered="false">
{{ toFaDigits(planned.length) }} باز
</a-tag>
</a-space>
</template>
<template #extra>
<a-button type="primary" @click="goCreate">
<template #icon><PlusOutlined /></template>
افزودن فعالیت
</a-button>
</template>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
:style="{ marginBottom: `${token.margin}px` }"
/>
<a-spin :spinning="loading">
<a-empty
v-if="!loading && !planned.length"
:image="AEmpty.PRESENTED_IMAGE_SIMPLE"
:style="{ paddingBlock: `${token.paddingLG}px` }"
>
<template #description>
<a-flex vertical align="center" :gap="token.marginXS">
<a-typography-title :level="5" :style="{ margin: 0 }">
هنوز فعالیتی برای این معامله نیست
</a-typography-title>
<a-typography-paragraph
type="secondary"
:style="{ margin: 0, maxWidth: '28rem', textAlign: 'center' }"
>
یک تماس، جلسه یا پیگیری زمان‌بندی کن تا معامله از دست نرود.
</a-typography-paragraph>
</a-flex>
</template>
<a-button type="primary" @click="goCreate">
<template #icon><PlusOutlined /></template>
افزودن اولین فعالیت
</a-button>
</a-empty>
<a-flex v-else-if="planned.length" vertical :gap="token.marginSM">
<a-flex
v-for="item in planned"
:key="item.id"
align="center"
justify="space-between"
:gap="token.marginSM"
wrap="wrap"
:style="rowStyle(dueTone(item.dueAt))"
role="button"
tabindex="0"
@click="goDetail(item.id)"
@keydown.enter="goDetail(item.id)"
>
<a-flex align="center" :gap="token.marginSM" :style="{ minWidth: 0, flex: '1 1 220px' }">
<a-avatar :size="40" :style="avatarStyle(dueTone(item.dueAt))">
<template #icon>
<WarningOutlined v-if="dueTone(item.dueAt) === 'overdue'" />
<ClockCircleOutlined v-else-if="dueTone(item.dueAt) === 'today'" />
<component :is="typeIcon(item.type)" v-else />
</template>
</a-avatar>
<a-flex vertical :gap="4" :style="{ minWidth: 0 }">
<a-typography-text strong :ellipsis="{ tooltip: item.title }">
{{ item.title }}
</a-typography-text>
<a-space :size="8" wrap>
<a-tag :bordered="false">{{ typeLabel(item) }}</a-tag>
<a-tag :color="dueTagColor(dueTone(item.dueAt))" :bordered="false">
{{ dueLabel(item.dueAt) }}
</a-tag>
<a-typography-text v-if="item.assignee?.fullName" type="secondary">
{{ item.assignee.fullName }}
</a-typography-text>
</a-space>
</a-flex>
</a-flex>
<LeftOutlined :style="{ color: token.colorTextQuaternary, flexShrink: 0 }" />
</a-flex>
</a-flex>
<template v-if="closed.length">
<a-divider v-if="planned.length" :style="{ margin: `${token.marginMD}px 0` }" />
<a-collapse
v-model:activeKey="closedKeys"
ghost
:bordered="false"
:style="{
background: 'transparent',
marginTop: planned.length ? 0 : `${token.marginSM}px`,
}"
>
<a-collapse-panel key="closed">
<template #header>
<a-typography-text type="secondary">
{{ toFaDigits(closed.length) }} فعالیت انجام‌شده یا لغو شده
</a-typography-text>
</template>
<a-flex vertical :gap="token.marginSM">
<a-flex
v-for="item in closed"
:key="item.id"
align="center"
justify="space-between"
:gap="token.marginSM"
wrap="wrap"
:style="rowStyle('none', true)"
role="button"
tabindex="0"
@click="goDetail(item.id)"
@keydown.enter="goDetail(item.id)"
>
<a-flex
align="center"
:gap="token.marginSM"
:style="{ minWidth: 0, flex: '1 1 220px' }"
>
<a-avatar
:size="40"
:style="{
backgroundColor: token.colorFillSecondary,
color: token.colorTextSecondary,
}"
>
<template #icon>
<component :is="typeIcon(item.type)" />
</template>
</a-avatar>
<a-flex vertical :gap="4" :style="{ minWidth: 0 }">
<a-typography-text :ellipsis="{ tooltip: item.title }">
{{ item.title }}
</a-typography-text>
<a-space :size="8" wrap>
<a-tag :bordered="false">{{ statusLabel(item.status) }}</a-tag>
<a-typography-text type="secondary">
{{ dueLabel(item.dueAt) }}
</a-typography-text>
</a-space>
</a-flex>
</a-flex>
<LeftOutlined :style="{ color: token.colorTextQuaternary, flexShrink: 0 }" />
</a-flex>
</a-flex>
</a-collapse-panel>
</a-collapse>
</template>
</a-spin>
</a-card>
</template>
+48 -30
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref } from 'vue';
import {
CalendarOutlined,
CheckOutlined,
@@ -18,6 +18,7 @@ import {
Typography,
theme,
} from 'ant-design-vue';
import DealQuickActivityPopover from '@/components/pipeline/DealQuickActivityPopover.vue';
import { userAvatarSrc } from '@/lib/userAvatars';
import type { ActivityPriority } from '@mento/shared';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
@@ -51,6 +52,11 @@ const emit = defineEmits<{
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const activityOpen = ref(false);
const highlighted = computed(
() => props.selected || props.peeking || activityOpen.value,
);
const showAmount = computed(
() => !props.copy.hideAmount && props.deal.amount != null,
);
@@ -103,11 +109,11 @@ const cardStyle = computed(() => ({
transform: props.dragging ? 'scale(0.98)' : 'none',
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
margin: 0,
position: props.peeking ? 'relative' : undefined,
zIndex: props.peeking ? 2 : undefined,
background: props.selected || props.peeking ? token.value.colorPrimaryBg : token.value.colorBgContainer,
borderColor: props.selected || props.peeking ? token.value.colorPrimary : token.value.colorBorderSecondary,
boxShadow: props.peeking
position: highlighted.value ? 'relative' : undefined,
zIndex: highlighted.value ? 2 : undefined,
background: highlighted.value ? token.value.colorPrimaryBg : token.value.colorBgContainer,
borderColor: highlighted.value ? token.value.colorPrimary : token.value.colorBorderSecondary,
boxShadow: highlighted.value
? `0 0 0 2px ${token.value.colorPrimary}`
: props.dragging
? token.value.boxShadow
@@ -235,32 +241,44 @@ const cardStyle = computed(() => ({
{{ deal.date }}
</a-typography-text>
</a-space>
<a-typography-text
v-if="showAmount"
strong
dir="ltr"
:style="{ margin: 0, whiteSpace: 'nowrap', flexShrink: 0 }"
>
{{ money.number }}
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
{{ money.unit }}
<a-space :size="4" align="center" :style="{ flexShrink: 0 }">
<a-typography-text
v-if="showAmount"
strong
dir="ltr"
:style="{ margin: 0, whiteSpace: 'nowrap' }"
>
{{ money.number }}
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
{{ money.unit }}
</a-typography-text>
</a-typography-text>
</a-typography-text>
<a-button
v-if="copy.quickView && !selectMode"
type="text"
:aria-label="previewing ? 'بستن پیش‌نمایش' : 'پیش‌نمایش فعالیت'"
:aria-expanded="previewing"
:style="{ color: token.colorTextSecondary }"
@click.stop="emit('open', deal.id)"
>
<template #icon><i class="chev" aria-hidden="true" :style="chevronStyle" /></template>
</a-button>
<a-tooltip v-else-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
<a-button
v-if="copy.quickView && !selectMode"
type="text"
:aria-label="previewing ? 'بستن پیش‌نمایش' : 'پیش‌نمایش فعالیت'"
:aria-expanded="previewing"
:style="{ color: token.colorTextSecondary }"
@click.stop="emit('open', deal.id)"
>
<template #icon><i class="chev" aria-hidden="true" :style="chevronStyle" /></template>
</a-button>
<a-tooltip v-else-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
</span>
</a-tooltip>
<span
v-if="copy.quickActivity && !selectMode"
@click.stop
@mousedown.stop
>
<DealQuickActivityPopover
:deal="deal"
@update:open="activityOpen = $event"
/>
</span>
</a-tooltip>
</a-space>
</a-flex>
</a-flex>
</a-card>
@@ -0,0 +1,376 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
Alert as AAlert,
Button as AButton,
Col as ACol,
Divider as ADivider,
Flex as AFlex,
FormItem as AFormItem,
Input as AInput,
Row as ARow,
Select as ASelect,
Space as ASpace,
Tooltip as ATooltip,
Typography,
message,
theme,
} from 'ant-design-vue';
import { CalendarOutlined, CloseOutlined, FormOutlined } from '@ant-design/icons-vue';
import type { ActivityDto, ActivityType, CreateActivityRequest } from '@mento/shared';
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { api } from '@/lib/api';
import { formatLocalDateInput } from '@/lib/jalali';
import type { PipelineDeal } from '@/lib/pipeline-board';
const { Title: ATypographyTitle, Link: ATypographyLink } = Typography;
const props = defineProps<{
deal: PipelineDeal;
disabled?: boolean;
}>();
const emit = defineEmits<{
'update:open': [open: boolean];
}>();
const router = useRouter();
const { token } = theme.useToken();
const open = ref(false);
const rendered = ref(false);
const placed = ref(false);
const panelEl = ref<HTMLElement | null>(null);
const frame = ref({ left: 12, top: 12, width: 360, maxHeight: 480, shift: 88 });
let hideTimer = 0;
let reveal = false;
const VIEW_PAD = 16;
const saving = ref(false);
const error = ref('');
const title = ref('');
const type = ref<ActivityType>('task');
const dueDate = ref('');
const dueTime = ref('');
const typeOptions = computed(() =>
(Object.entries(ACTIVITY_TYPE_LABELS) as Array<[ActivityType, string]>)
.filter(([value]) => value !== 'note')
.map(([value, label]) => ({ value, label })),
);
const fieldItemStyle = computed(() => ({
marginBottom: 0,
width: '100%',
}));
function resetForm() {
title.value = `پیگیری: ${props.deal.title}`;
type.value = 'task';
dueDate.value = formatLocalDateInput(new Date());
dueTime.value = '';
error.value = '';
}
function cardEl() {
return document.querySelector<HTMLElement>(
`.ant-card[data-card-id="${CSS.escape(props.deal.id)}"]`,
);
}
function columnScroller(el: HTMLElement) {
let node = el.parentElement;
while (node) {
const style = getComputedStyle(node);
const scrolls = style.overflowY === 'auto' || style.overflowY === 'scroll';
if (scrolls && node.scrollHeight > node.clientHeight + 2) return node;
node = node.parentElement;
}
return null;
}
function panelExtent() {
const content = panelEl.value?.scrollHeight ?? 0;
return content > 80 ? content : 360;
}
function liftColumn(el: HTMLElement, cardTop: number, height: number, vh: number) {
const overflow = cardTop + height - (vh - VIEW_PAD);
if (overflow <= 8) return;
const scroller = columnScroller(el);
if (!scroller) return;
const room = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
const delta = Math.min(overflow, Math.max(0, room));
if (delta <= 8) return;
const motion = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
scroller.scrollBy({ top: delta, behavior: motion });
}
function measure() {
const el = cardEl();
if (!el) return;
let rect = el.getBoundingClientRect();
if (rect.width < 8) return;
const vw = window.innerWidth;
const vh = window.innerHeight;
const width = Math.min(360, vw - 24);
const gap = 10;
const side: 'left' | 'right' = rect.left >= vw - rect.right ? 'left' : 'right';
let left = side === 'left' ? rect.left - gap - width : rect.right + gap;
left = Math.max(12, Math.min(left, vw - width - 12));
const height = Math.min(panelExtent(), Math.max(200, vh - VIEW_PAD * 2));
if (reveal) {
reveal = false;
liftColumn(el, rect.top, height, vh);
rect = el.getBoundingClientRect();
}
let top = rect.top;
const maxTop = vh - VIEW_PAD - height;
if (top > maxTop) top = Math.max(VIEW_PAD, maxTop);
if (top < VIEW_PAD) top = VIEW_PAD;
const maxHeight = Math.max(200, vh - VIEW_PAD - top);
const travel = Math.min(100, Math.round(width * 0.28));
frame.value = { left, top, width, maxHeight, shift: side === 'left' ? travel : -travel };
}
const panelStyle = computed(() => ({
position: 'fixed' as const,
zIndex: 1050,
top: `${frame.value.top}px`,
left: `${frame.value.left}px`,
width: `${frame.value.width}px`,
maxHeight: `${frame.value.maxHeight}px`,
overflow: 'auto' as const,
boxSizing: 'border-box' as const,
padding: `${token.value.padding}px`,
background: token.value.colorBgElevated,
color: token.value.colorText,
border: `1px solid ${token.value.colorBorderSecondary}`,
borderRadius: `${token.value.borderRadiusLG}px`,
boxShadow: token.value.boxShadowSecondary,
transform: placed.value ? 'translate3d(0, 0, 0)' : `translate3d(${frame.value.shift}px, 0, 0)`,
opacity: placed.value ? 1 : 0,
transition: 'transform 220ms cubic-bezier(.2, .8, .2, 1), opacity 180ms ease',
pointerEvents: placed.value ? ('auto' as const) : ('none' as const),
}));
function setOpen(next: boolean) {
if (open.value === next) return;
open.value = next;
emit('update:open', next);
}
function toggleOpen() {
if (props.disabled) return;
setOpen(!open.value);
}
function close() {
setOpen(false);
}
function onViewport() {
if (open.value) measure();
}
function onPointerDown(event: PointerEvent) {
if (!open.value || !placed.value) return;
const target = event.target;
if (!(target instanceof Node)) return;
if (panelEl.value?.contains(target)) return;
if (
target instanceof Element &&
target.closest('.ant-select-dropdown, #jalali-picker, .layer')
) {
return;
}
const card = cardEl();
if (card?.contains(target)) return;
close();
}
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape' && open.value) close();
}
watch(open, async (isOpen) => {
if (!isOpen) {
placed.value = false;
window.clearTimeout(hideTimer);
hideTimer = window.setTimeout(() => {
if (!open.value) rendered.value = false;
}, 230);
return;
}
window.clearTimeout(hideTimer);
resetForm();
rendered.value = true;
reveal = true;
measure();
await nextTick();
measure();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (open.value) {
placed.value = true;
void nextTick().then(() => measure());
}
});
});
});
onMounted(() => {
window.addEventListener('resize', onViewport);
window.addEventListener('scroll', onViewport, true);
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKey);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onViewport);
window.removeEventListener('scroll', onViewport, true);
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKey);
window.clearTimeout(hideTimer);
});
function dueAtIso(): string | null {
if (!dueDate.value) return null;
const time = dueTime.value.trim() || '10:30';
const due = new Date(`${dueDate.value}T${time}:00`);
return Number.isNaN(due.getTime()) ? null : due.toISOString();
}
function fullFormQuery() {
const query: Record<string, string> = { dealId: props.deal.id };
if (props.deal.personId) query.personId = props.deal.personId;
if (props.deal.companyId) query.companyId = props.deal.companyId;
if (title.value.trim()) query.title = title.value.trim();
if (type.value) query.type = type.value;
if (dueAtIso()) query.dueAt = dueAtIso()!;
return query;
}
function goFullForm() {
close();
void router.push({ name: 'activity-new', query: fullFormQuery() });
}
async function submit() {
error.value = '';
if (title.value.trim().length < 2) {
error.value = 'عنوان فعالیت را بنویسید.';
return;
}
const dueAt = dueAtIso();
if (!dueDate.value || !dueAt) {
error.value = 'تاریخ فعالیت را مشخص کنید.';
return;
}
const body: CreateActivityRequest = {
title: title.value.trim(),
type: type.value,
dueAt,
dealId: props.deal.id,
personId: props.deal.personId || null,
companyId: props.deal.companyId || null,
assigneeId: props.deal.ownerId || null,
};
saving.value = true;
try {
await api.post<ActivityDto>('/activities', body);
message.success('فعالیت ثبت شد');
close();
} catch {
error.value = 'ثبت فعالیت ناموفق بود.';
} finally {
saving.value = false;
}
}
</script>
<template>
<a-tooltip title="افزودن فعالیت">
<a-button
type="text"
:disabled="disabled"
aria-label="افزودن فعالیت سریع"
:aria-expanded="open"
:style="{
color: open ? token.colorPrimary : token.colorTextSecondary,
width: '28px',
minWidth: '28px',
height: '28px',
padding: 0,
}"
@click.stop="toggleOpen"
@mousedown.stop
@dragstart.stop.prevent
>
<template #icon><CalendarOutlined /></template>
</a-button>
</a-tooltip>
<Teleport to="body">
<div
v-if="rendered"
ref="panelEl"
role="dialog"
dir="rtl"
:aria-label="`ایجاد فعالیت سریع برای ${deal.title}`"
:style="panelStyle"
>
<a-flex justify="space-between" align="center" :style="{ marginBottom: `${token.marginSM}px` }">
<a-typography-title :level="5" :style="{ margin: 0 }">ایجاد فعالیت سریع</a-typography-title>
<a-button type="text" aria-label="بستن" @click="close">
<template #icon><CloseOutlined /></template>
</a-button>
</a-flex>
<a-flex vertical :gap="token.marginSM">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-form-item label="عنوان" :style="fieldItemStyle">
<a-input v-model:value="title" placeholder="عنوان فعالیت" allow-clear />
</a-form-item>
<a-form-item label="نوع" :style="fieldItemStyle">
<a-select v-model:value="type" :options="typeOptions" style="width: 100%" />
</a-form-item>
<a-row :gutter="[12, 12]" align="top">
<a-col :span="12">
<a-form-item label="تاریخ" :style="fieldItemStyle">
<JalaliDateInput v-model="dueDate" placeholder="تاریخ" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="ساعت" :style="fieldItemStyle">
<JalaliDateInput
v-model="dueTime"
mode="time"
placeholder="اختیاری"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-divider :style="{ margin: `${token.marginXS}px 0` }" />
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-link @click="goFullForm">
<FormOutlined />
فرم کامل
</a-typography-link>
<a-space :size="8">
<a-button :disabled="saving" @click="close">انصراف</a-button>
<a-button type="primary" :loading="saving" @click="submit">ذخیره</a-button>
</a-space>
</a-flex>
</a-flex>
</div>
</Teleport>
</template>
+9 -2
View File
@@ -20,6 +20,8 @@ export type PipelineDeal = {
assigneeNames?: string[];
ownerId?: string | null;
assigneeIds?: string[];
personId?: string | null;
companyId?: string | null;
/** متن‌های قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */
searchFields?: Array<string | null | undefined>;
/** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده می‌شود */
@@ -36,6 +38,8 @@ export type PipelineUiCopy = {
hideAmount?: boolean;
/** کارت فعالیت: پرچم کنار عنوان و پنجرهٔ خلاصه به‌جای ورود مستقیم به جزئیات */
quickView?: boolean;
/** گوشه کارت: ایجاد سریع فعالیت برای معامله */
quickActivity?: boolean;
};
export const DEAL_BOARD_COPY: PipelineUiCopy = {
@@ -45,6 +49,7 @@ export const DEAL_BOARD_COPY: PipelineUiCopy = {
addItemAria: 'افزودن معامله به این مرحله',
itemMenuAria: 'گزینه‌های معامله',
listColTitle: 'معامله',
quickActivity: true,
};
export const PROJECT_BOARD_COPY: PipelineUiCopy = {
@@ -185,8 +190,8 @@ export function mapBoardToStages(
expectedCloseAt: string | null;
createdAt: string;
promotedFromPriorAt: string | null;
person: { fullName: string } | null;
company: { name: string } | null;
person: { id?: string; fullName: string } | null;
company: { id?: string; name: string } | null;
owner?: { id: string; fullName: string } | null;
assignees?: Array<{ id: string; fullName: string }>;
}>;
@@ -239,6 +244,8 @@ export function mapBoardToStages(
assigneeNames,
ownerId: deal.owner?.id ?? null,
assigneeIds: (deal.assignees ?? []).map((a) => a.id),
personId: deal.person?.id ?? null,
companyId: deal.company?.id ?? null,
searchFields: [
deal.notes,
deal.company?.name,
+10
View File
@@ -544,6 +544,16 @@ async function load() {
personId.value = incomingPerson;
}
leadId.value = typeof route.query.leadId === 'string' ? route.query.leadId : '';
const incomingDeal =
typeof route.query.dealId === 'string' ? route.query.dealId : '';
if (incomingDeal) {
dealId.value = incomingDeal;
const linkedDeal = deals.value.find((d) => d.id === incomingDeal);
if (linkedDeal?.person?.id && !personId.value) {
await ensurePersonLoaded(linkedDeal.person.id);
personId.value = linkedDeal.person.id;
}
}
const incomingDef =
typeof route.query.typeDefId === 'string'
? activityTypes.byId.value.get(route.query.typeDefId)
+9
View File
@@ -45,6 +45,7 @@ import { toFaDigits } from '@/lib/jalali';
import { currencySuffix, formatMoney } from '@/lib/money';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import RelatedActivitiesPanel from '@/components/activities/RelatedActivitiesPanel.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
@@ -1014,6 +1015,14 @@ onMounted(load);
</a-form-item>
</a-card>
<RelatedActivitiesPanel
v-if="!isNew && id"
:style="{ marginTop: '16px' }"
:deal-id="id"
:person-id="personId || null"
:company-id="companyId || null"
:deal-title="title"
/>
<RelatedTasksPanel
v-if="!isNew && id"
:style="{ marginTop: '16px' }"