Replace the inline SVG icon set with the vuesax linear pack, add skeleton primitives for loading states, and keep page scroll inside the app shell. Co-authored-by: Cursor <cursoragent@cursor.com>
1051 lines
38 KiB
Vue
1051 lines
38 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, ref, watch, type Component } from 'vue';
|
||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||
import axios from 'axios';
|
||
import { api } from '@/lib/api';
|
||
import { useAuthStore } from '@/stores/auth';
|
||
import {
|
||
formatJalaliDayClock,
|
||
formatJalaliNumeric,
|
||
formatLocalDateInput,
|
||
formatLocalDateTimeInput,
|
||
parseLocalInput,
|
||
toFaDigits,
|
||
} from '@/lib/jalali';
|
||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
||
import { appendSpokenText } from '@/lib/spoken-text';
|
||
import { brand } from '@mento/shared';
|
||
import UserAvatar from '@/components/UserAvatar.vue';
|
||
import PersonFormView from '@/views/PersonFormView.vue';
|
||
import ActivityFieldsEditor from '@/components/activities/ActivityFieldsEditor.vue';
|
||
import { typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
||
import {
|
||
Affix as AAffix,
|
||
Alert as AAlert,
|
||
Avatar as AAvatar,
|
||
Breadcrumb as ABreadcrumb,
|
||
BreadcrumbItem as ABreadcrumbItem,
|
||
Button as AButton,
|
||
Card as ACard,
|
||
Checkbox as ACheckbox,
|
||
Col as ACol,
|
||
Descriptions as ADescriptions,
|
||
DescriptionsItem as ADescriptionsItem,
|
||
Divider as ADivider,
|
||
Empty as AEmpty,
|
||
Flex as AFlex,
|
||
Form as AForm,
|
||
FormItem as AFormItem,
|
||
Input as AInput,
|
||
InputNumber as AInputNumber,
|
||
Modal as AModal,
|
||
RadioButton as ARadioButton,
|
||
RadioGroup as ARadioGroup,
|
||
Row as ARow,
|
||
Select as ASelect,
|
||
Space as ASpace,
|
||
Switch as ASwitch,
|
||
Tag as ATag,
|
||
Textarea as ATextarea,
|
||
Typography,
|
||
} from 'ant-design-vue';
|
||
import {
|
||
AimOutlined,
|
||
BankOutlined,
|
||
BellOutlined,
|
||
CalendarOutlined,
|
||
CheckOutlined,
|
||
FlagOutlined,
|
||
FileTextOutlined,
|
||
MailOutlined,
|
||
MessageOutlined,
|
||
MinusOutlined,
|
||
PhoneOutlined,
|
||
PlusOutlined,
|
||
PushpinOutlined,
|
||
SendOutlined,
|
||
SoundOutlined,
|
||
StarOutlined,
|
||
TeamOutlined,
|
||
ThunderboltOutlined,
|
||
CarOutlined,
|
||
} from '@/icons/ant-bridge';
|
||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||
import type {
|
||
ActivityBoardDto,
|
||
ActivityDto,
|
||
ActivityFieldValue,
|
||
ActivityFieldValues,
|
||
ActivityPriority,
|
||
ActivityType,
|
||
ActivityTypeDefDto,
|
||
CreateActivityRequest,
|
||
DealDto,
|
||
OrgUserDto,
|
||
PaginatedResponse,
|
||
PersonDto,
|
||
PipelineBoardDto,
|
||
} from '@mento/shared';
|
||
|
||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||
|
||
const TYPE_ICON_COMPONENTS: Record<string, Component> = {
|
||
phone: PhoneOutlined,
|
||
users: TeamOutlined,
|
||
check: CheckOutlined,
|
||
chat: MessageOutlined,
|
||
mail: MailOutlined,
|
||
building: BankOutlined,
|
||
calendar: CalendarOutlined,
|
||
star: StarOutlined,
|
||
flag: FlagOutlined,
|
||
briefcase: FileTextOutlined,
|
||
document: FileTextOutlined,
|
||
target: AimOutlined,
|
||
truck: CarOutlined,
|
||
send: SendOutlined,
|
||
megaphone: SoundOutlined,
|
||
pin: PushpinOutlined,
|
||
bell: BellOutlined,
|
||
sparkle: ThunderboltOutlined,
|
||
};
|
||
|
||
function typeIconComp(icon?: string): Component {
|
||
return (icon && TYPE_ICON_COMPONENTS[icon]) || PhoneOutlined;
|
||
}
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
embedded?: boolean;
|
||
entityId?: string;
|
||
initialPersonId?: string;
|
||
initialType?: ActivityType;
|
||
initialTitle?: string;
|
||
initialStageId?: string;
|
||
/** گروه فعالیت پیشفرض برای فعالیت جدید (گروهی که کاربر در آن است) */
|
||
initialBoardId?: string;
|
||
}>(),
|
||
{ embedded: false },
|
||
);
|
||
const emit = defineEmits<{ saved: []; cancel: [] }>();
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const auth = useAuthStore();
|
||
const id = computed(() => {
|
||
if (props.embedded) return props.entityId ?? '';
|
||
return typeof route.params.id === 'string' ? route.params.id : '';
|
||
});
|
||
const isNew = computed(() =>
|
||
props.embedded ? !props.entityId : route.name === 'activity-new',
|
||
);
|
||
|
||
const loading = ref(false);
|
||
const saving = ref(false);
|
||
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('');
|
||
const type = ref<ActivityType | ''>('');
|
||
const typeDefId = ref('');
|
||
const fieldValues = ref<ActivityFieldValues>({});
|
||
/** نوع غیرفعالی که فعالیت فعلی دارد — در لیست انتخاب نگه داشته میشود */
|
||
const keepTypeId = ref('');
|
||
const priority = ref<ActivityPriority>('medium');
|
||
const description = ref('');
|
||
const tagsRaw = ref('');
|
||
const dueDate = ref('');
|
||
const dueTime = ref('10:30');
|
||
const durationMinutes = ref(30);
|
||
const personId = ref('');
|
||
const dealId = ref('');
|
||
const assigneeIds = ref<string[]>([]);
|
||
const isDone = ref(false);
|
||
const needsNote = ref(false);
|
||
const reminderAt = ref('');
|
||
const reminderNotifyApp = ref(true);
|
||
const reminderNotifySms = ref(false);
|
||
const loadedReminderAt = ref('');
|
||
const reminderSentAt = ref<string | null>(null);
|
||
const reminderChanged = computed(() => reminderAt.value !== loadedReminderAt.value);
|
||
const reminderSentLabel = computed(() =>
|
||
reminderSentAt.value && !reminderChanged.value
|
||
? formatJalaliDayClock(new Date(reminderSentAt.value))
|
||
: '',
|
||
);
|
||
const smsAssigneesWithoutMobile = computed(() =>
|
||
users.value
|
||
.filter((u) => assigneeIds.value.includes(u.id) && !u.mobile?.trim())
|
||
.map((u) => u.fullName),
|
||
);
|
||
|
||
const personQuery = ref('');
|
||
const personResults = ref<PersonDto[] | null>(null);
|
||
const personSearching = ref(false);
|
||
const newPersonOpen = ref(false);
|
||
let personSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
||
let personSearchSeq = 0;
|
||
|
||
const personOptions = computed(() => personResults.value ?? people.value);
|
||
|
||
const personSelectOptions = computed(() =>
|
||
personOptions.value.map((p) => ({
|
||
value: p.id,
|
||
label: p.fullName,
|
||
company: p.company?.name ?? '',
|
||
phone: p.mobile || p.phone || '',
|
||
})),
|
||
);
|
||
|
||
const assigneeOptions = computed(() =>
|
||
users.value.map((u) => ({ value: u.id, label: u.fullName })),
|
||
);
|
||
|
||
const boardOptions = computed(() =>
|
||
boards.value.map((b) => ({ value: b.id, label: b.name })),
|
||
);
|
||
|
||
const dealOptions = computed(() => [
|
||
{ value: '', label: 'بدون معامله' },
|
||
...personDeals.value.map((d) => ({ value: d.id, label: d.title })),
|
||
]);
|
||
|
||
function rememberPerson(p: PersonDto) {
|
||
if (!people.value.some((x) => x.id === p.id)) people.value = [p, ...people.value];
|
||
}
|
||
|
||
async function ensurePersonLoaded(pid: string) {
|
||
if (!pid || people.value.some((p) => p.id === pid)) return;
|
||
try {
|
||
const { data } = await api.get<PersonDto>(`/people/${pid}`);
|
||
rememberPerson(data);
|
||
} catch {
|
||
/* person may have been removed */
|
||
}
|
||
}
|
||
|
||
function pickPerson(p: PersonDto) {
|
||
rememberPerson(p);
|
||
personId.value = p.id;
|
||
}
|
||
|
||
function onPersonChange(value: unknown) {
|
||
personId.value = value == null ? '' : String(value);
|
||
}
|
||
|
||
function openNewPerson() {
|
||
newPersonOpen.value = true;
|
||
}
|
||
|
||
function onPersonCreated(p?: PersonDto) {
|
||
newPersonOpen.value = false;
|
||
if (!p) return;
|
||
personQuery.value = '';
|
||
personResults.value = null;
|
||
pickPerson(p);
|
||
}
|
||
|
||
watch(personQuery, (raw) => {
|
||
if (personSearchTimer) clearTimeout(personSearchTimer);
|
||
const q = raw.trim();
|
||
if (!q) {
|
||
personResults.value = null;
|
||
personSearching.value = false;
|
||
return;
|
||
}
|
||
personSearching.value = true;
|
||
const seq = ++personSearchSeq;
|
||
personSearchTimer = setTimeout(async () => {
|
||
try {
|
||
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
|
||
params: { q, limit: 30 },
|
||
});
|
||
if (seq === personSearchSeq) personResults.value = data.items;
|
||
} catch {
|
||
if (seq === personSearchSeq) personResults.value = [];
|
||
} finally {
|
||
if (seq === personSearchSeq) personSearching.value = false;
|
||
}
|
||
}, 300);
|
||
});
|
||
|
||
const typeOptions = computed<ActivityTypeDefDto[]>(() =>
|
||
activityTypes.types.value.filter((t) => t.isActive || t.id === keepTypeId.value),
|
||
);
|
||
|
||
const typeSelectOptions = computed(() =>
|
||
typeOptions.value.map((t) => ({
|
||
value: t.id,
|
||
label: t.name,
|
||
icon: t.icon,
|
||
color: t.color,
|
||
fieldsCount: t.fields.length,
|
||
})),
|
||
);
|
||
|
||
function pickType(def: ActivityTypeDefDto) {
|
||
typeDefId.value = def.id;
|
||
type.value = def.baseType;
|
||
}
|
||
|
||
function onTypeChange(value: unknown) {
|
||
const def = typeOptions.value.find((t) => t.id === String(value));
|
||
if (def) pickType(def);
|
||
}
|
||
|
||
function isEmptyValue(v: ActivityFieldValue | undefined): boolean {
|
||
if (v == null || v === '') return true;
|
||
if (Array.isArray(v)) return v.length === 0;
|
||
if (typeof v === 'object') {
|
||
return !v.userIds?.length && !v.people?.length && !v.guests?.length;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
const priorityOptions: Array<{ value: ActivityPriority; label: string; tone: string }> = [
|
||
{ value: 'very_high', label: 'خیلی زیاد', tone: 'very-high' },
|
||
{ value: 'high', label: 'زیاد', tone: 'high' },
|
||
{ value: 'medium', label: 'متوسط', tone: 'medium' },
|
||
{ value: 'low', label: 'کم', tone: 'low' },
|
||
{ value: 'very_low', label: 'خیلی کم', tone: 'very-low' },
|
||
];
|
||
|
||
const typeMeta = computed(() => activityTypes.byId.value.get(typeDefId.value) ?? null);
|
||
const typeFields = computed(() => typeMeta.value?.fields ?? []);
|
||
const selectedPerson = computed(() => people.value.find((p) => p.id === personId.value) ?? null);
|
||
const selectedAssignees = computed(() =>
|
||
assigneeIds.value
|
||
.map((id) => users.value.find((u) => u.id === id))
|
||
.filter((u): u is (typeof users.value)[number] => Boolean(u)),
|
||
);
|
||
const selectedDeal = computed(() => deals.value.find((d) => d.id === dealId.value) ?? null);
|
||
const personDeals = computed(() =>
|
||
deals.value.filter((d) => !personId.value || d.person?.id === personId.value),
|
||
);
|
||
const personSub = computed(
|
||
() => selectedDeal.value?.title || selectedPerson.value?.company?.name || '',
|
||
);
|
||
|
||
const duePreview = computed(() => {
|
||
if (!dueDate.value) return '—';
|
||
const d = parseLocalInput(`${dueDate.value}T${dueTime.value || '00:00'}`);
|
||
if (!d) return '—';
|
||
return `${formatJalaliNumeric(d)} ${toFaDigits((dueTime.value || '00:00').replace(':', ':'))}`;
|
||
});
|
||
|
||
const statusText = computed(() => (isDone.value ? 'انجام شده' : 'انجام نشده'));
|
||
const priorityText = computed(
|
||
() => priorityOptions.find((p) => p.value === priority.value)?.label || '—',
|
||
);
|
||
const pageTitle = computed(() => (isNew.value ? 'ایجاد فعالیت جدید' : 'ویرایش فعالیت'));
|
||
const summaryReady = computed(() => Boolean(title.value.trim() || type.value || selectedPerson.value));
|
||
|
||
function pad(n: number) {
|
||
return String(n).padStart(2, '0');
|
||
}
|
||
|
||
function splitDue(iso: string | null) {
|
||
if (!iso) return;
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return;
|
||
dueDate.value = formatLocalDateInput(d);
|
||
dueTime.value = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
}
|
||
|
||
function defaultDue() {
|
||
const d = new Date();
|
||
d.setHours(10, 30, 0, 0);
|
||
dueDate.value = formatLocalDateInput(d);
|
||
dueTime.value = '10:30';
|
||
}
|
||
|
||
function resetForm() {
|
||
title.value = '';
|
||
type.value = '';
|
||
typeDefId.value = '';
|
||
fieldValues.value = {};
|
||
keepTypeId.value = '';
|
||
priority.value = 'medium';
|
||
description.value = '';
|
||
tagsRaw.value = '';
|
||
defaultDue();
|
||
durationMinutes.value = 30;
|
||
personId.value = '';
|
||
dealId.value = '';
|
||
assigneeIds.value = auth.user?.id ? [auth.user.id] : [];
|
||
isDone.value = false;
|
||
needsNote.value = false;
|
||
reminderAt.value = '';
|
||
loadedReminderAt.value = '';
|
||
reminderSentAt.value = null;
|
||
reminderNotifyApp.value = true;
|
||
reminderNotifySms.value = false;
|
||
error.value = '';
|
||
}
|
||
|
||
watch(personId, () => {
|
||
if (dealId.value && !personDeals.value.some((d) => d.id === dealId.value)) {
|
||
dealId.value = personDeals.value[0]?.id ?? '';
|
||
} else if (!dealId.value && personDeals.value.length === 1) {
|
||
dealId.value = personDeals.value[0].id;
|
||
}
|
||
});
|
||
|
||
const noteCard = ref<HTMLElement | null>(null);
|
||
const tagPreview = computed(() => parseTags(tagsRaw.value).map((t) => t.replace(/^#/, '')));
|
||
|
||
function focusNote() {
|
||
void nextTick(() => {
|
||
noteCard.value?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
noteCard.value?.querySelector<HTMLTextAreaElement>('textarea')?.focus({ preventScroll: true });
|
||
});
|
||
}
|
||
|
||
watch(needsNote, (on) => {
|
||
if (on && !loading.value) focusNote();
|
||
});
|
||
|
||
async function load() {
|
||
loading.value = true;
|
||
error.value = '';
|
||
try {
|
||
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 ?? '';
|
||
keepTypeId.value = typeDefId.value;
|
||
fieldValues.value = { ...(data.fields ?? {}) };
|
||
priority.value = data.priority ?? 'medium';
|
||
description.value = data.description ?? '';
|
||
needsNote.value = Boolean(data.description?.trim());
|
||
tagsRaw.value = (data.tags ?? []).join('، ');
|
||
splitDue(data.dueAt);
|
||
durationMinutes.value = data.durationMinutes ?? 30;
|
||
reminderAt.value = data.reminderAt
|
||
? formatLocalDateTimeInput(new Date(data.reminderAt))
|
||
: '';
|
||
loadedReminderAt.value = reminderAt.value;
|
||
reminderSentAt.value = data.reminderSentAt ?? null;
|
||
reminderNotifyApp.value = data.reminderNotifyApp ?? true;
|
||
reminderNotifySms.value = data.reminderNotifySms ?? false;
|
||
if (data.person?.id) await ensurePersonLoaded(data.person.id);
|
||
personId.value = data.person?.id ?? '';
|
||
dealId.value = data.deal?.id ?? '';
|
||
assigneeIds.value = data.assignees?.length
|
||
? data.assignees.map((a) => a.id)
|
||
: data.assignee?.id
|
||
? [data.assignee.id]
|
||
: auth.user?.id
|
||
? [auth.user.id]
|
||
: [];
|
||
isDone.value = data.status === 'done';
|
||
} else {
|
||
resetForm();
|
||
const incomingPerson =
|
||
props.initialPersonId ||
|
||
(typeof route.query.personId === 'string' ? route.query.personId : '');
|
||
if (incomingPerson) {
|
||
await ensurePersonLoaded(incomingPerson);
|
||
personId.value = incomingPerson;
|
||
}
|
||
const incomingDef =
|
||
typeof route.query.typeDefId === 'string'
|
||
? activityTypes.byId.value.get(route.query.typeDefId)
|
||
: undefined;
|
||
const incomingType = props.initialType || route.query.type;
|
||
const baseDef =
|
||
typeof incomingType === 'string'
|
||
? activityTypes.systemOf.value[incomingType as ActivityType]
|
||
: undefined;
|
||
const def = incomingDef?.isActive ? incomingDef : baseDef;
|
||
if (def) pickType(def);
|
||
const incomingTitle =
|
||
props.initialTitle ||
|
||
(typeof route.query.title === 'string' ? route.query.title : '');
|
||
if (incomingTitle) title.value = incomingTitle;
|
||
if (typeof route.query.dueAt === 'string' && route.query.dueAt) {
|
||
splitDue(new Date(route.query.dueAt).toISOString());
|
||
}
|
||
}
|
||
} catch {
|
||
error.value = 'بارگذاری فرم فعالیت ناموفق بود.';
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function parseTags(raw: string): string[] {
|
||
return [
|
||
...new Set(
|
||
raw
|
||
.split(/[,،#\n]/)
|
||
.map((t) => t.trim())
|
||
.filter(Boolean),
|
||
),
|
||
].slice(0, 40);
|
||
}
|
||
|
||
function payload(): CreateActivityRequest {
|
||
const due =
|
||
dueDate.value && dueTime.value ? new Date(`${dueDate.value}T${dueTime.value}:00`) : null;
|
||
const reminder = parseLocalInput(reminderAt.value);
|
||
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(
|
||
Object.entries(fieldValues.value).filter(([k, v]) => keys.has(k) && !isEmptyValue(v)),
|
||
),
|
||
priority: priority.value,
|
||
description: description.value || undefined,
|
||
tags: parseTags(tagsRaw.value),
|
||
dueAt: due && !Number.isNaN(due.getTime()) ? due.toISOString() : null,
|
||
durationMinutes: durationMinutes.value || null,
|
||
reminderAt: reminder && !Number.isNaN(reminder.getTime()) ? reminder.toISOString() : null,
|
||
reminderNotifyApp: reminder ? reminderNotifyApp.value : false,
|
||
reminderNotifySms: reminder ? reminderNotifySms.value : false,
|
||
personId: personId.value || null,
|
||
companyId: selectedPerson.value?.company?.id ?? null,
|
||
dealId: dealId.value || null,
|
||
assigneeIds: [...assigneeIds.value],
|
||
assigneeId: assigneeIds.value[0] || null,
|
||
};
|
||
}
|
||
|
||
function validate() {
|
||
if (!type.value) return 'نوع فعالیت را انتخاب کنید.';
|
||
if (title.value.trim().length < 2) return 'عنوان فعالیت را بنویسید.';
|
||
const missing = typeFields.value.find(
|
||
(f) => f.required && f.type !== 'checkbox' && isEmptyValue(fieldValues.value[f.key]),
|
||
);
|
||
if (missing) return `فیلد «${missing.label}» را تکمیل کنید.`;
|
||
if (!dueDate.value || !dueTime.value) return 'تاریخ و ساعت را مشخص کنید.';
|
||
if (!assigneeIds.value.length) return 'حداقل یک مسئول فعالیت را انتخاب کنید.';
|
||
if (needsNote.value && !description.value.trim()) return 'توضیح این فعالیت را بنویسید.';
|
||
if (reminderAt.value && reminderChanged.value) {
|
||
const when = parseLocalInput(reminderAt.value);
|
||
if (when && when.getTime() < Date.now() - 60_000) {
|
||
return 'زمان یادآوری گذشته است؛ زمانی در آینده انتخاب کنید.';
|
||
}
|
||
}
|
||
if (reminderAt.value && !reminderNotifyApp.value && !reminderNotifySms.value) {
|
||
return 'برای یادآوری، اعلان یا پیامک را انتخاب کنید.';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
async function submit(next: 'detail' | 'new' = 'detail') {
|
||
const message = validate();
|
||
if (message) {
|
||
error.value = message;
|
||
if (needsNote.value && !description.value.trim()) focusNote();
|
||
return;
|
||
}
|
||
saving.value = true;
|
||
error.value = '';
|
||
try {
|
||
let savedId = id.value;
|
||
if (isNew.value) {
|
||
const { data } = await api.post<ActivityDto>('/activities', {
|
||
...payload(),
|
||
...(props.initialStageId ? { stageId: props.initialStageId } : {}),
|
||
});
|
||
savedId = data.id;
|
||
if (isDone.value) {
|
||
await api.patch(`/activities/${savedId}/complete`);
|
||
}
|
||
} else {
|
||
await api.patch(`/activities/${id.value}`, {
|
||
...payload(),
|
||
status: isDone.value ? 'done' : 'open',
|
||
});
|
||
}
|
||
if (props.embedded) {
|
||
emit('saved');
|
||
return;
|
||
}
|
||
if (next === 'new') {
|
||
if (!isNew.value) {
|
||
await router.push({ name: 'activity-new' });
|
||
}
|
||
resetForm();
|
||
return;
|
||
}
|
||
await router.replace({ name: 'activity-detail', params: { id: savedId } });
|
||
} catch (e) {
|
||
if (axios.isAxiosError(e)) {
|
||
if (!e.response) error.value = 'اتصال به سرور برقرار نشد.';
|
||
else {
|
||
const msg = (e.response.data as { message?: string | string[] })?.message;
|
||
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره ناموفق بود');
|
||
}
|
||
} else {
|
||
error.value = 'ذخیره ناموفق بود';
|
||
}
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
function bumpDuration(delta: number) {
|
||
durationMinutes.value = Math.min(24 * 60, Math.max(5, durationMinutes.value + delta));
|
||
}
|
||
|
||
function onDurationChange(value: number | string | null) {
|
||
durationMinutes.value = value == null || value === '' ? 0 : Number(value);
|
||
}
|
||
|
||
function cancel() {
|
||
if (props.embedded) {
|
||
emit('cancel');
|
||
return;
|
||
}
|
||
if (!isNew.value && id.value) {
|
||
void router.push({ name: 'activity-detail', params: { id: id.value } });
|
||
return;
|
||
}
|
||
void router.push({ name: 'activities' });
|
||
}
|
||
|
||
watch([id, isNew], () => void load(), { immediate: true });
|
||
</script>
|
||
|
||
<template>
|
||
<a-flex vertical :gap="16">
|
||
<a-flex
|
||
v-if="!embedded"
|
||
justify="space-between"
|
||
align="center"
|
||
wrap="wrap"
|
||
:gap="12"
|
||
>
|
||
<a-flex vertical :gap="4">
|
||
<a-breadcrumb>
|
||
<a-breadcrumb-item>
|
||
<RouterLink :to="{ name: 'activities' }">فعالیتها</RouterLink>
|
||
</a-breadcrumb-item>
|
||
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
|
||
</a-breadcrumb>
|
||
<a-typography-title :level="3" :style="{ margin: 0 }">{{ pageTitle }}</a-typography-title>
|
||
</a-flex>
|
||
<a-space :size="8" wrap>
|
||
<a-button type="primary" :disabled="saving" :loading="saving" @click="submit('detail')">
|
||
<template #icon><CheckOutlined /></template>
|
||
{{ saving ? 'در حال ذخیره…' : 'ذخیره فعالیت' }}
|
||
</a-button>
|
||
<a-button :disabled="saving" @click="submit('new')">
|
||
<template #icon><PlusOutlined /></template>
|
||
ذخیره و ایجاد جدید
|
||
</a-button>
|
||
<a-button :disabled="saving" @click="cancel">انصراف</a-button>
|
||
</a-space>
|
||
</a-flex>
|
||
|
||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||
|
||
<CrmSkeleton :loading="loading">
|
||
<CrmSkeletonCard :rows="8">
|
||
<a-row :gutter="[24, 24]" align="top">
|
||
<a-col :xs="24" :lg="17" :xl="18">
|
||
<a-card :bordered="false">
|
||
<a-form layout="vertical" @finish="submit('detail')">
|
||
<a-divider orientation="right">نوع و عنوان</a-divider>
|
||
<a-row :gutter="[16, 0]">
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="نوع فعالیت" required>
|
||
<a-select
|
||
:value="typeDefId || undefined"
|
||
:options="typeSelectOptions"
|
||
placeholder="انتخاب کنید"
|
||
style="width: 100%"
|
||
@update:value="onTypeChange"
|
||
>
|
||
<template #option="{ label, icon, color, fieldsCount }">
|
||
<a-space :size="8">
|
||
<a-avatar
|
||
shape="square"
|
||
:style="typeColorStyle(color)"
|
||
>
|
||
<template #icon>
|
||
<component :is="typeIconComp(icon)" />
|
||
</template>
|
||
</a-avatar>
|
||
<a-typography-text>{{ label }}</a-typography-text>
|
||
<a-typography-text v-if="fieldsCount" type="secondary">
|
||
{{ toFaDigits(fieldsCount) }} فیلد
|
||
</a-typography-text>
|
||
</a-space>
|
||
</template>
|
||
</a-select>
|
||
<a-typography-text v-if="!typeOptions.length" type="danger">
|
||
نوع فعالیتی تعریف نشده است.
|
||
</a-typography-text>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="عنوان فعالیت" required>
|
||
<a-input
|
||
v-model:value="title"
|
||
:maxlength="200"
|
||
placeholder="مثلاً: تماس پیگیری جهت ارائه پیشفاکتور"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col v-if="boards.length > 1" :xs="24" :md="12">
|
||
<a-form-item label="گروه فعالیت">
|
||
<a-select
|
||
v-model:value="boardId"
|
||
:options="boardOptions"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<template v-if="typeFields.length">
|
||
<a-divider orientation="right">اطلاعات {{ typeMeta?.name }}</a-divider>
|
||
<ActivityFieldsEditor v-model="fieldValues" :fields="typeFields" :users="users" />
|
||
</template>
|
||
|
||
<a-divider orientation="right">وضعیت</a-divider>
|
||
<a-row :gutter="[16, 0]">
|
||
<a-col :span="24">
|
||
<a-space :size="24" wrap>
|
||
<a-space :size="8">
|
||
<a-switch v-model:checked="isDone" />
|
||
<a-typography-text>این فعالیت انجام شده</a-typography-text>
|
||
</a-space>
|
||
<a-space :size="8">
|
||
<a-switch v-model:checked="needsNote" />
|
||
<a-typography-text>این فعالیت نیاز به توضیح دارد</a-typography-text>
|
||
</a-space>
|
||
</a-space>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<a-divider orientation="right">زمانبندی</a-divider>
|
||
<a-row :gutter="[16, 0]">
|
||
<a-col :xs="24" :sm="12" :md="8">
|
||
<a-form-item label="تاریخ" required>
|
||
<JalaliDateInput v-model="dueDate" required style="width: 100%" />
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :sm="12" :md="8">
|
||
<a-form-item label="ساعت" required>
|
||
<JalaliDateInput v-model="dueTime" mode="time" required style="width: 100%" />
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :sm="12" :md="8">
|
||
<a-form-item label="مدت فعالیت (دقیقه)" required>
|
||
<a-space :size="8" align="center">
|
||
<a-button aria-label="افزایش" @click="bumpDuration(5)">
|
||
<template #icon><PlusOutlined /></template>
|
||
</a-button>
|
||
<a-input-number
|
||
:value="durationMinutes"
|
||
:min="0"
|
||
:max="1440"
|
||
style="width: 100%"
|
||
@update:value="onDurationChange"
|
||
/>
|
||
<a-button aria-label="کاهش" @click="bumpDuration(-5)">
|
||
<template #icon><MinusOutlined /></template>
|
||
</a-button>
|
||
</a-space>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<a-divider orientation="right">مسئولان و مشتری</a-divider>
|
||
<a-row :gutter="[16, 0]">
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="مسئولان انجام این فعالیت" required extra="میتوانید چند نفر را بهعنوان مسئول انتخاب کنید.">
|
||
<a-select
|
||
v-model:value="assigneeIds"
|
||
mode="multiple"
|
||
:options="assigneeOptions"
|
||
placeholder="انتخاب مسئول"
|
||
style="width: 100%"
|
||
>
|
||
<template #option="{ label }">
|
||
<a-space :size="8">
|
||
<UserAvatar :name="label" :size="24" />
|
||
<a-typography-text>{{ label }}</a-typography-text>
|
||
</a-space>
|
||
</template>
|
||
</a-select>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="شخص / مشتری مرتبط (اختیاری)">
|
||
<template #extra>
|
||
<a-button type="link" @click="openNewPerson">
|
||
<template #icon><PlusOutlined /></template>
|
||
افزودن شخص جدید
|
||
</a-button>
|
||
<a-typography-text v-if="personSub" type="secondary">
|
||
{{ personSub }}
|
||
</a-typography-text>
|
||
</template>
|
||
<a-select
|
||
:value="personId || undefined"
|
||
show-search
|
||
allow-clear
|
||
:filter-option="false"
|
||
:options="personSelectOptions"
|
||
:not-found-content="personSearching ? 'در حال جستجو…' : 'شخصی پیدا نشد.'"
|
||
placeholder="بدون شخص — انتخاب کنید"
|
||
style="width: 100%"
|
||
@search="(q: string) => (personQuery = q)"
|
||
@update:value="onPersonChange"
|
||
>
|
||
<template #option="{ label, company, phone }">
|
||
<a-flex justify="space-between" align="center" :gap="8" :style="{ width: '100%' }">
|
||
<a-flex vertical :gap="0">
|
||
<a-typography-text>{{ label }}</a-typography-text>
|
||
<a-typography-text v-if="company" type="secondary">{{ company }}</a-typography-text>
|
||
</a-flex>
|
||
<a-typography-text v-if="phone" type="secondary" dir="ltr">
|
||
{{ phone }}
|
||
</a-typography-text>
|
||
</a-flex>
|
||
</template>
|
||
</a-select>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col v-if="personDeals.length > 1" :xs="24" :md="12">
|
||
<a-form-item label="فرصت مرتبط">
|
||
<a-select
|
||
v-model:value="dealId"
|
||
:options="dealOptions"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<a-divider orientation="right">اولویت</a-divider>
|
||
<a-row :gutter="[16, 0]">
|
||
<a-col :span="24">
|
||
<a-form-item>
|
||
<a-radio-group v-model:value="priority">
|
||
<a-radio-button
|
||
v-for="opt in priorityOptions"
|
||
:key="opt.value"
|
||
:value="opt.value"
|
||
>
|
||
<FlagOutlined />
|
||
{{ opt.label }}
|
||
</a-radio-button>
|
||
</a-radio-group>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<a-divider orientation="right">یادآوری</a-divider>
|
||
<a-row :gutter="[16, 0]">
|
||
<a-col :xs="24" :md="12">
|
||
<a-form-item label="زمان یادآوری">
|
||
<JalaliDateInput
|
||
v-model="reminderAt"
|
||
mode="datetime"
|
||
icon="bell"
|
||
placeholder="یادآوری تنظیم نشده است"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col v-if="reminderAt" :span="24">
|
||
<a-form-item label="چگونه یادآوری شود؟">
|
||
<a-space :size="24" wrap>
|
||
<a-checkbox v-model:checked="reminderNotifyApp">
|
||
اعلان داخل پنل
|
||
<a-typography-text type="secondary">
|
||
(در زنگوله اعلانهای {{ brand.nameFa }} نشان داده میشود)
|
||
</a-typography-text>
|
||
</a-checkbox>
|
||
<a-checkbox v-model:checked="reminderNotifySms">
|
||
پیامک به مسئول
|
||
<a-typography-text type="secondary">
|
||
(به شماره موبایل مسئول فعالیت ارسال میشود)
|
||
</a-typography-text>
|
||
</a-checkbox>
|
||
</a-space>
|
||
</a-form-item>
|
||
<a-alert
|
||
v-if="reminderAt && !reminderNotifyApp && !reminderNotifySms"
|
||
type="warning"
|
||
show-icon
|
||
message="حداقل یکی از روشهای یادآوری را انتخاب کنید."
|
||
:style="{ marginBottom: '8px' }"
|
||
/>
|
||
<a-alert
|
||
v-if="reminderNotifySms && smsAssigneesWithoutMobile.length"
|
||
type="warning"
|
||
show-icon
|
||
:message="`شمارهٔ موبایل برای ${smsAssigneesWithoutMobile.join('، ')} ثبت نشده؛ پیامک برایشان ارسال نمیشود.`"
|
||
:style="{ marginBottom: '8px' }"
|
||
/>
|
||
<a-alert
|
||
v-if="reminderSentLabel"
|
||
type="info"
|
||
show-icon
|
||
:message="`این یادآوری در ${reminderSentLabel} ارسال شد. برای یادآوری دوباره، زمان جدیدی انتخاب کنید.`"
|
||
/>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<a-divider orientation="right">توضیحات و برچسب</a-divider>
|
||
<div ref="noteCard">
|
||
<a-row :gutter="[16, 0]">
|
||
<a-col :span="24">
|
||
<a-form-item :label="needsNote ? 'توضیحات و یادداشت *' : 'توضیحات و یادداشت'">
|
||
<template #extra>
|
||
<VoiceToTextButton
|
||
@transcript="(text: string) => (description = appendSpokenText(description, text))"
|
||
/>
|
||
</template>
|
||
<a-textarea
|
||
v-model:value="description"
|
||
:rows="4"
|
||
placeholder="جزئیات تماس، نتیجه جلسه یا یادداشت مرتبط را بنویسید…"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="24">
|
||
<a-form-item
|
||
label="هشتگ / برچسب"
|
||
extra="چند برچسب را با کاما (،) یا # جدا کنید؛ برچسبها در جزئیات و لیست فعالیتها نمایش داده میشوند و با جستجوی همان کلمه پیدا میشوند."
|
||
>
|
||
<a-input
|
||
v-model:value="tagsRaw"
|
||
placeholder="پیگیری، فوری (با کاما یا #)"
|
||
/>
|
||
<a-space v-if="tagPreview.length" :size="6" wrap :style="{ marginTop: '8px' }">
|
||
<a-tag v-for="t in tagPreview" :key="t" color="cyan" :bordered="false">
|
||
#{{ t }}
|
||
</a-tag>
|
||
</a-space>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
</div>
|
||
|
||
<a-flex justify="flex-end" :style="{ marginTop: '8px' }">
|
||
<a-space :size="8" wrap>
|
||
<a-button type="primary" html-type="submit" :disabled="saving" :loading="saving">
|
||
<template #icon><CheckOutlined /></template>
|
||
{{ saving ? 'در حال ذخیره…' : 'ذخیره فعالیت' }}
|
||
</a-button>
|
||
<a-button
|
||
v-if="embedded"
|
||
:disabled="saving"
|
||
@click="cancel"
|
||
>
|
||
انصراف
|
||
</a-button>
|
||
</a-space>
|
||
</a-flex>
|
||
</a-form>
|
||
</a-card>
|
||
</a-col>
|
||
|
||
<a-col :xs="24" :lg="7" :xl="6">
|
||
<a-affix :offset-top="24">
|
||
<a-card title="خلاصه فعالیت" :bordered="false">
|
||
<a-empty
|
||
v-if="!summaryReady"
|
||
description="با تکمیل اطلاعات، خلاصه فعالیت در اینجا نمایش داده میشود."
|
||
/>
|
||
<a-descriptions v-else :column="1" bordered>
|
||
<a-descriptions-item label="نوع فعالیت">
|
||
{{ typeMeta?.name || '—' }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="تاریخ و ساعت">
|
||
{{ duePreview }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="مدت فعالیت">
|
||
{{ toFaDigits(durationMinutes) }} دقیقه
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="مسئولان">
|
||
{{
|
||
selectedAssignees.length
|
||
? selectedAssignees.map((u) => u.fullName).join('، ')
|
||
: '—'
|
||
}}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="مشتری مرتبط">
|
||
{{ selectedPerson?.fullName || '—' }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="وضعیت انجام">
|
||
<a-tag :color="isDone ? 'success' : 'default'" :bordered="false">
|
||
{{ statusText }}
|
||
</a-tag>
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="اولویت">
|
||
<a-tag
|
||
:color="
|
||
priority === 'very_high' || priority === 'high'
|
||
? 'error'
|
||
: priority === 'medium'
|
||
? 'warning'
|
||
: 'default'
|
||
"
|
||
:bordered="false"
|
||
>
|
||
{{ priorityText }}
|
||
</a-tag>
|
||
</a-descriptions-item>
|
||
</a-descriptions>
|
||
</a-card>
|
||
</a-affix>
|
||
</a-col>
|
||
</a-row>
|
||
</CrmSkeletonCard>
|
||
</CrmSkeleton>
|
||
|
||
<a-modal
|
||
v-model:open="newPersonOpen"
|
||
title="شخص جدید"
|
||
:footer="null"
|
||
width="900px"
|
||
destroy-on-close
|
||
>
|
||
<PersonFormView
|
||
v-if="newPersonOpen"
|
||
embedded
|
||
:initial-query="personQuery"
|
||
@saved="onPersonCreated"
|
||
@cancel="newPersonOpen = false"
|
||
/>
|
||
</a-modal>
|
||
</a-flex>
|
||
</template>
|