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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' }"
|
||||
|
||||
Reference in New Issue
Block a user