Files
CRM_Montzar/apps/web/src/views/ActivityFormView.vue
T
MahdiandCursor 6c6e32e099 feat(web): adopt vuesax icons and shared skeleton loading
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>
2026-11-05 14:46:35 +03:30

1051 lines
38 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>