Files
CRM_Montzar/apps/web/src/views/ActivityFormView.vue
T

2698 lines
77 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.
This file contains Unicode characters that might be confused with other characters. 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, onUnmounted, ref, watch } 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 { prepareActivityAttachment } from '@/lib/activity-attachment';
import { startVoiceRecorder, type VoiceRecorder } from '@/lib/voice-note';
import UiIcon from '@/components/UiIcon.vue';
import { brand } from '@mento/shared';
import UserAvatar from '@/components/UserAvatar.vue';
import PersonFormView from '@/views/PersonFormView.vue';
import {
UiBadge,
UiButton,
UiInput,
UiModal,
UiPageHeader,
UiSelect,
UiTextarea,
} from '@/components/ui';
import ActivityFieldsEditor from '@/components/activities/ActivityFieldsEditor.vue';
import { typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import type {
ActivityBoardDto,
ActivityDto,
ActivityImageDto,
ActivityFieldValue,
ActivityFieldValues,
ActivityPriority,
ActivityType,
ActivityTypeDefDto,
CreateActivityRequest,
DealDto,
OrgUserDto,
PaginatedResponse,
PersonDto,
PipelineBoardDto,
TeamDto,
} from '@mento/shared';
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 teams = ref<TeamDto[]>([]);
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 PHOTO_TOO_LARGE = 'حجم عکس باید زیر 200 کیلوبایت باشد';
const PHOTO_LIMIT = 8;
type PendingPhoto = { localId: string; name: string; dataUrl: string; file: File };
type SavedPhoto = ActivityImageDto & { previewUrl: string };
const pendingPhotos = ref<PendingPhoto[]>([]);
const savedPhotos = ref<SavedPhoto[]>([]);
const photoBusy = ref(false);
const photoError = ref('');
const createdActivityId = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const VOICE_LIMIT = 5;
type PendingVoice = { localId: string; name: string; url: string; file: File };
type SavedVoice = ActivityImageDto & { previewUrl: string };
const pendingVoices = ref<PendingVoice[]>([]);
const savedVoices = ref<SavedVoice[]>([]);
const voiceBusy = ref(false);
const voiceError = ref('');
const recording = ref(false);
const recordSeconds = ref(0);
type PendingFile = { localId: string; name: string; file: File };
type SavedFile = ActivityImageDto;
const pendingFiles = ref<PendingFile[]>([]);
const savedFiles = ref<SavedFile[]>([]);
let voiceRecorder: VoiceRecorder | null = null;
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 typeOpen = ref(false);
const assigneeOpen = ref(false);
const assigneeQuery = ref('');
const assigneeSearchInput = ref<HTMLInputElement | null>(null);
const personOpen = ref(false);
const personQuery = ref('');
const personResults = ref<PersonDto[] | null>(null);
const personSearching = ref(false);
const personSearchInput = ref<HTMLInputElement | null>(null);
const newPersonOpen = ref(false);
let personSearchTimer: ReturnType<typeof setTimeout> | undefined;
let personSearchSeq = 0;
const personOptions = computed(() => personResults.value ?? people.value);
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 toggleAssigneeMenu() {
assigneeOpen.value = !assigneeOpen.value;
if (assigneeOpen.value) {
typeOpen.value = false;
personOpen.value = false;
void nextTick(() => assigneeSearchInput.value?.focus());
}
}
watch(assigneeOpen, (open) => {
if (!open) assigneeQuery.value = '';
});
function togglePersonMenu() {
personOpen.value = !personOpen.value;
if (personOpen.value) {
typeOpen.value = false;
assigneeOpen.value = false;
void nextTick(() => personSearchInput.value?.focus());
}
}
function pickPerson(p: PersonDto) {
rememberPerson(p);
personId.value = p.id;
personOpen.value = false;
}
function clearPerson() {
personId.value = '';
personOpen.value = false;
}
function openNewPerson() {
personOpen.value = false;
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),
);
function pickType(def: ActivityTypeDefDto) {
typeDefId.value = def.id;
type.value = def.baseType;
typeOpen.value = false;
}
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 pickedRoleIds = ref<string[]>([]);
const pickedTeamIds = ref<string[]>([]);
const roleGroups = computed(() => {
const map = new Map<string, { id: string; name: string; userIds: string[] }>();
for (const user of users.value) {
if (user.isActive === false || !user.orgRoleId || !user.orgRoleName) continue;
const row = map.get(user.orgRoleId) ?? {
id: user.orgRoleId,
name: user.orgRoleName,
userIds: [],
};
row.userIds.push(user.id);
map.set(user.orgRoleId, row);
}
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name, 'fa'));
});
const teamGroups = computed(() => {
const active = new Set(users.value.filter((user) => user.isActive !== false).map((user) => user.id));
return teams.value
.map((team) => ({
id: team.id,
name: team.name,
userIds: [...new Set(team.members.map((member) => member.userId).filter((id) => active.has(id)))],
}))
.filter((team) => team.userIds.length > 0)
.sort((a, b) => a.name.localeCompare(b.name, 'fa'));
});
function normalizeSearch(value: string) {
return value.replace(/ي/g, 'ی').replace(/ك/g, 'ک').replace(/\u200c/g, ' ').trim().toLowerCase();
}
const assigneeSearch = computed(() => normalizeSearch(assigneeQuery.value));
const filteredRoleGroups = computed(() => {
const q = assigneeSearch.value;
if (!q) return roleGroups.value;
return roleGroups.value.filter((role) => normalizeSearch(role.name).includes(q));
});
const filteredTeamGroups = computed(() => {
const q = assigneeSearch.value;
if (!q) return teamGroups.value;
return teamGroups.value.filter((team) => normalizeSearch(team.name).includes(q));
});
const filteredUsers = computed(() => {
const q = assigneeSearch.value;
if (!q) return users.value;
return users.value.filter(
(user) =>
normalizeSearch(user.fullName).includes(q) ||
normalizeSearch(user.orgRoleName ?? '').includes(q),
);
});
function coversAll(userIds: string[]) {
return userIds.length > 0 && userIds.every((id) => assigneeIds.value.includes(id));
}
const selectedRoles = computed(() =>
roleGroups.value.filter((role) => pickedRoleIds.value.includes(role.id)),
);
const selectedTeams = computed(() =>
teamGroups.value.filter((team) => pickedTeamIds.value.includes(team.id)),
);
const extraAssignees = computed(() => {
const covered = new Set([
...selectedRoles.value.flatMap((role) => role.userIds),
...selectedTeams.value.flatMap((team) => team.userIds),
]);
return selectedAssignees.value.filter((user) => !covered.has(user.id));
});
const assigneePickerLabel = computed(() => {
const groups = [...selectedRoles.value, ...selectedTeams.value];
const extras = extraAssignees.value;
if (!assigneeIds.value.length) return 'انتخاب مسئول';
if (groups.length && !extras.length) {
if (groups.length === 1) {
return `${groups[0]!.name} (${toFaDigits(groups[0]!.userIds.length)} نفر)`;
}
if (groups.length === 2) return `${groups[0]!.name} و ${groups[1]!.name}`;
const moreWord =
selectedTeams.value.length && !selectedRoles.value.length
? 'تیم'
: selectedRoles.value.length && !selectedTeams.value.length
? 'نقش'
: 'مورد';
return `${groups[0]!.name} و ${toFaDigits(groups.length - 1)} ${moreWord} دیگر`;
}
if (groups.length && extras.length) {
return `${groups.map((group) => group.name).join('، ')} و ${toFaDigits(extras.length)} نفر`;
}
if (selectedAssignees.value.length === 1) return selectedAssignees.value[0]!.fullName;
return `${selectedAssignees.value[0]!.fullName} و ${toFaDigits(selectedAssignees.value.length - 1)} نفر دیگر`;
});
const assigneeSummaryLabel = computed(() => {
if (!assigneeIds.value.length) return '—';
const parts = [
...selectedRoles.value.map((role) => `${role.name} (${toFaDigits(role.userIds.length)} نفر)`),
...selectedTeams.value.map((team) => `${team.name} (${toFaDigits(team.userIds.length)} نفر)`),
...extraAssignees.value.map((user) => user.fullName),
];
return parts.join('، ');
});
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 = '';
clearPhotos();
createdActivityId.value = '';
defaultDue();
durationMinutes.value = 30;
personId.value = '';
dealId.value = '';
assigneeIds.value = auth.user?.id ? [auth.user.id] : [];
pickedRoleIds.value = [];
pickedTeamIds.value = [];
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, teamsRes] = 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[] })),
api.get<TeamDto[]>('/teams').catch(() => ({ data: [] as TeamDto[] })),
activityTypes.load(true),
]);
people.value = peopleRes.data.items;
deals.value = boardRes.data.stages.flatMap((s) => s.deals);
users.value = usersRes.data;
teams.value = teamsRes.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]
: [];
refreshPickedGroups();
isDone.value = data.status === 'done';
await loadSavedPhotos(data);
} 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 refreshPickedGroups() {
pickedRoleIds.value = roleGroups.value
.filter((role) => {
if (!coversAll(role.userIds)) return false;
if (role.userIds.length >= 2) return true;
return pickedRoleIds.value.includes(role.id);
})
.map((role) => role.id);
pickedTeamIds.value = teamGroups.value
.filter((team) => {
if (!coversAll(team.userIds)) return false;
if (team.userIds.length >= 2) return true;
return pickedTeamIds.value.includes(team.id);
})
.map((team) => team.id);
}
function coveredByPickedGroups() {
return new Set([
...roleGroups.value
.filter((role) => pickedRoleIds.value.includes(role.id))
.flatMap((role) => role.userIds),
...teamGroups.value
.filter((team) => pickedTeamIds.value.includes(team.id))
.flatMap((team) => team.userIds),
]);
}
function toggleAssignee(userId: string) {
const idx = assigneeIds.value.indexOf(userId);
if (idx >= 0) {
if (assigneeIds.value.length <= 1) return;
assigneeIds.value = assigneeIds.value.filter((id) => id !== userId);
} else {
assigneeIds.value = [...assigneeIds.value, userId];
}
refreshPickedGroups();
}
function toggleGroup(kind: 'role' | 'team', groupId: string, userIds: string[]) {
const picked = kind === 'role' ? pickedRoleIds : pickedTeamIds;
if (picked.value.includes(groupId)) {
picked.value = picked.value.filter((id) => id !== groupId);
const still = coveredByPickedGroups();
assigneeIds.value = assigneeIds.value.filter((id) => !userIds.includes(id) || still.has(id));
if (!assigneeIds.value.length && auth.user?.id) assigneeIds.value = [auth.user.id];
refreshPickedGroups();
return;
}
const onlyDefaultUser =
pickedRoleIds.value.length === 0 &&
pickedTeamIds.value.length === 0 &&
assigneeIds.value.length === 1 &&
assigneeIds.value[0] === auth.user?.id &&
!userIds.includes(auth.user?.id ?? '');
picked.value = [...picked.value, groupId];
assigneeIds.value = onlyDefaultUser
? [...userIds]
: [...new Set([...assigneeIds.value, ...userIds])];
refreshPickedGroups();
}
function toggleRole(roleId: string, userIds: string[]) {
toggleGroup('role', roleId, userIds);
}
function toggleTeam(teamId: string, userIds: string[]) {
toggleGroup('team', teamId, userIds);
}
function clearVoices() {
voiceRecorder?.cancel();
voiceRecorder = null;
recording.value = false;
recordSeconds.value = 0;
for (const voice of pendingVoices.value) URL.revokeObjectURL(voice.url);
pendingVoices.value = [];
for (const voice of savedVoices.value) {
if (voice.previewUrl.startsWith('blob:')) URL.revokeObjectURL(voice.previewUrl);
}
savedVoices.value = [];
pendingFiles.value = [];
savedFiles.value = [];
voiceError.value = '';
voiceBusy.value = false;
}
function clearPhotos() {
pendingPhotos.value = [];
for (const photo of savedPhotos.value) {
if (photo.previewUrl.startsWith('blob:')) URL.revokeObjectURL(photo.previewUrl);
}
savedPhotos.value = [];
photoError.value = '';
photoBusy.value = false;
clearVoices();
}
async function loadSavedPhotos(activity: ActivityDto) {
clearPhotos();
const images = activity.images ?? [];
const loadedPhotos: SavedPhoto[] = [];
const loadedVoices: SavedVoice[] = [];
const loadedFiles: SavedFile[] = [];
for (const image of images) {
const mime = image.mimeType.toLowerCase();
if (!mime.startsWith('image/') && !mime.startsWith('audio/')) {
loadedFiles.push(image);
continue;
}
let previewUrl = '';
try {
const { data } = await api.get<Blob>(`/activities/${activity.id}/images/${image.id}`, {
responseType: 'blob',
});
previewUrl = URL.createObjectURL(data);
} catch {
previewUrl = '';
}
if (mime.startsWith('audio/')) loadedVoices.push({ ...image, previewUrl });
else loadedPhotos.push({ ...image, previewUrl });
}
savedPhotos.value = loadedPhotos;
savedVoices.value = loadedVoices;
savedFiles.value = loadedFiles;
}
const FILE_LIMIT = 8;
async function onFilesPicked(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = '';
if (!files.length) return;
photoError.value = '';
voiceError.value = '';
for (const file of files) {
photoBusy.value = true;
try {
const prepared = await prepareActivityAttachment(file);
if (prepared.kind === 'image') {
if (pendingPhotos.value.length + savedPhotos.value.length >= PHOTO_LIMIT) {
photoError.value = `حداکثر ${toFaDigits(PHOTO_LIMIT)} عکس برای هر فعالیت.`;
continue;
}
pendingPhotos.value = [
...pendingPhotos.value,
{ localId: crypto.randomUUID(), name: prepared.name, dataUrl: prepared.previewUrl, file: prepared.file },
];
} else if (prepared.kind === 'audio') {
if (voiceCount() >= VOICE_LIMIT) {
URL.revokeObjectURL(prepared.previewUrl);
voiceError.value = `حداکثر ${toFaDigits(VOICE_LIMIT)} صوت برای هر فعالیت.`;
continue;
}
pendingVoices.value = [
...pendingVoices.value,
{ localId: crypto.randomUUID(), name: prepared.name, url: prepared.previewUrl, file: prepared.file },
];
} else {
if (pendingFiles.value.length + savedFiles.value.length >= FILE_LIMIT) {
photoError.value = `حداکثر ${toFaDigits(FILE_LIMIT)} فایل برای هر فعالیت.`;
continue;
}
pendingFiles.value = [
...pendingFiles.value,
{ localId: crypto.randomUUID(), name: prepared.name, file: prepared.file },
];
}
} catch (error) {
photoError.value = error instanceof Error ? error.message : 'افزودن فایل ناموفق بود.';
} finally {
photoBusy.value = false;
}
}
}
function removePendingPhoto(localId: string) {
pendingPhotos.value = pendingPhotos.value.filter((photo) => photo.localId !== localId);
if (photoError.value === PHOTO_TOO_LARGE) photoError.value = '';
}
async function removeSavedPhoto(photo: SavedPhoto) {
const activityId = id.value || createdActivityId.value;
if (!activityId) return;
await api.delete(`/activities/${activityId}/images/${photo.id}`);
if (photo.previewUrl.startsWith('blob:')) URL.revokeObjectURL(photo.previewUrl);
savedPhotos.value = savedPhotos.value.filter((item) => item.id !== photo.id);
}
function formatVoiceClock(total: number) {
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return toFaDigits(`${minutes}:${String(seconds).padStart(2, '0')}`);
}
function formatVoiceBytes(bytes: number) {
if (bytes >= 1024 * 1024) return `${toFaDigits((bytes / (1024 * 1024)).toFixed(1))} مگابایت`;
if (bytes >= 1024) return `${toFaDigits(Math.round(bytes / 1024))} کیلوبایت`;
return `${toFaDigits(bytes)} بایت`;
}
function voiceCount() {
return pendingVoices.value.length + savedVoices.value.length;
}
function addPendingVoice(file: File) {
if (voiceCount() >= VOICE_LIMIT) {
voiceError.value = `حداکثر ${toFaDigits(VOICE_LIMIT)} صوت برای هر فعالیت.`;
return;
}
pendingVoices.value = [
...pendingVoices.value,
{ localId: crypto.randomUUID(), name: file.name, url: URL.createObjectURL(file), file },
];
}
async function toggleRecording() {
voiceError.value = '';
if (voiceRecorder) {
const handle = voiceRecorder;
voiceRecorder = null;
recording.value = false;
try {
addPendingVoice(await handle.stop());
} catch (error) {
voiceError.value = error instanceof Error ? error.message : 'ضبط صوت ناموفق بود.';
}
return;
}
if (voiceCount() >= VOICE_LIMIT) {
voiceError.value = `حداکثر ${toFaDigits(VOICE_LIMIT)} صوت برای هر فعالیت.`;
return;
}
try {
recordSeconds.value = 0;
voiceRecorder = await startVoiceRecorder({
onTick: (seconds) => {
recordSeconds.value = seconds;
},
onAutoStop: (file) => {
voiceRecorder = null;
recording.value = false;
addPendingVoice(file);
},
});
recording.value = true;
} catch (error) {
voiceError.value = error instanceof Error ? error.message : 'ضبط صوت ناموفق بود.';
}
}
function removePendingVoice(localId: string) {
const voice = pendingVoices.value.find((item) => item.localId === localId);
if (voice) URL.revokeObjectURL(voice.url);
pendingVoices.value = pendingVoices.value.filter((item) => item.localId !== localId);
}
async function removeSavedVoice(voice: SavedVoice) {
const activityId = id.value || createdActivityId.value;
if (!activityId) return;
await api.delete(`/activities/${activityId}/images/${voice.id}`);
if (voice.previewUrl.startsWith('blob:')) URL.revokeObjectURL(voice.previewUrl);
savedVoices.value = savedVoices.value.filter((item) => item.id !== voice.id);
}
function removePendingFile(localId: string) {
pendingFiles.value = pendingFiles.value.filter((item) => item.localId !== localId);
}
async function removeSavedFile(file: SavedFile) {
const activityId = id.value || createdActivityId.value;
if (!activityId) return;
await api.delete(`/activities/${activityId}/images/${file.id}`);
savedFiles.value = savedFiles.value.filter((item) => item.id !== file.id);
}
async function uploadPendingFiles(activityId: string) {
const queue = [...pendingFiles.value];
for (const file of queue) {
const form = new FormData();
form.append('file', file.file, file.name);
const { data } = await api.post<ActivityImageDto>(`/activities/${activityId}/images`, form);
pendingFiles.value = pendingFiles.value.filter((item) => item.localId !== file.localId);
savedFiles.value = [...savedFiles.value, data];
}
}
async function uploadPendingVoices(activityId: string) {
const queue = [...pendingVoices.value];
for (const voice of queue) {
const form = new FormData();
form.append('file', voice.file, voice.name);
const { data } = await api.post<ActivityImageDto>(`/activities/${activityId}/images`, form);
URL.revokeObjectURL(voice.url);
pendingVoices.value = pendingVoices.value.filter((item) => item.localId !== voice.localId);
savedVoices.value = [...savedVoices.value, { ...data, previewUrl: URL.createObjectURL(voice.file) }];
}
}
async function uploadPendingPhotos(activityId: string) {
const queue = [...pendingPhotos.value];
for (const photo of queue) {
const form = new FormData();
form.append('file', photo.file, photo.name);
const { data } = await api.post<ActivityImageDto>(`/activities/${activityId}/images`, form);
pendingPhotos.value = pendingPhotos.value.filter((item) => item.localId !== photo.localId);
savedPhotos.value = [...savedPhotos.value, { ...data, previewUrl: photo.dataUrl }];
}
}
onUnmounted(() => {
voiceRecorder?.cancel();
for (const photo of savedPhotos.value) {
if (photo.previewUrl.startsWith('blob:')) URL.revokeObjectURL(photo.previewUrl);
}
for (const voice of pendingVoices.value) URL.revokeObjectURL(voice.url);
for (const voice of savedVoices.value) {
if (voice.previewUrl.startsWith('blob:')) URL.revokeObjectURL(voice.previewUrl);
}
});
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() &&
!pendingPhotos.value.length &&
!savedPhotos.value.length
) {
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 || createdActivityId.value;
if (!savedId) {
const { data } = await api.post<ActivityDto>('/activities', {
...payload(),
...(props.initialStageId ? { stageId: props.initialStageId } : {}),
});
savedId = data.id;
createdActivityId.value = data.id;
if (isDone.value) {
await api.patch(`/activities/${savedId}/complete`);
}
} else {
await api.patch(`/activities/${savedId}`, {
...payload(),
status: isDone.value ? 'done' : 'open',
});
}
await uploadPendingPhotos(savedId);
await uploadPendingVoices(savedId);
await uploadPendingFiles(savedId);
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 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>
<section class="crm-page" :class="{ embedded }" @click="typeOpen = assigneeOpen = personOpen = false">
<UiPageHeader
v-if="!embedded"
:title="pageTitle"
:back-to="{ name: 'activities' }"
>
<template #lead>
<span class="crumb">
<RouterLink :to="{ name: 'activities' }">فعالیت‌ها</RouterLink>
<span>›</span>
<strong>{{ pageTitle }}</strong>
</span>
</template>
<template #actions>
<UiButton
variant="primary"
type="button"
:disabled="saving"
:loading="saving"
@click="submit('detail')"
>
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : 'ذخیره فعالیت' }}
</UiButton>
<UiButton
variant="outline"
type="button"
:disabled="saving"
@click="submit('new')"
>
<UiIcon name="plus" :size="16" />
ذخیره و ایجاد جدید
</UiButton>
<UiButton variant="ghost" type="button" :disabled="saving" @click="cancel">
انصراف
</UiButton>
</template>
</UiPageHeader>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else class="layout">
<form class="form" @submit.prevent="submit('detail')">
<div class="stack">
<div class="card pair">
<div class="field">
<label>نوع فعالیت <i>*</i></label>
<button class="picker type-pick" type="button" @click.stop="typeOpen = !typeOpen">
<span
class="type-ico"
:style="typeMeta ? typeColorStyle(typeMeta.color) : undefined"
>
<UiIcon :name="typeMeta?.icon || 'phone'" :size="16" />
</span>
<span :class="{ ghost: !typeMeta }">{{ typeMeta?.name || 'انتخاب کنید' }}</span>
<UiIcon name="chevron" :size="16" />
</button>
<ul v-if="typeOpen" class="menu">
<li v-for="opt in typeOptions" :key="opt.id">
<button type="button" :class="{ on: opt.id === typeDefId }" @click="pickType(opt)">
<span class="type-ico" :style="typeColorStyle(opt.color)">
<UiIcon :name="opt.icon" :size="14" />
</span>
{{ opt.name }}
<small v-if="opt.fields.length" class="type-fields-count">
{{ toFaDigits(opt.fields.length) }} فیلد
</small>
</button>
</li>
<li v-if="!typeOptions.length" class="menu-empty">نوع فعالیتی تعریف نشده است.</li>
</ul>
</div>
<div class="field">
<label for="title">عنوان فعالیت <i>*</i></label>
<UiInput
id="title"
v-model="title"
required
minlength="2"
placeholder="مثلاً: تماس پیگیری جهت ارائه پیش‌فاکتور"
/>
</div>
</div>
<div v-if="boards.length > 1" class="card">
<div class="field">
<label for="activity-board">گروه فعالیت</label>
<UiSelect id="activity-board" v-model="boardId">
<option v-for="b in boards" :key="b.id" :value="b.id">{{ b.name }}</option>
</UiSelect>
</div>
</div>
<div v-if="typeFields.length" class="card type-fields">
<p class="type-fields-title">
<span class="type-ico sm" :style="typeColorStyle(typeMeta!.color)">
<UiIcon :name="typeMeta!.icon" :size="13" />
</span>
اطلاعات {{ typeMeta!.name }}
</p>
<ActivityFieldsEditor v-model="fieldValues" :fields="typeFields" :users="users" />
</div>
<div class="card toggles">
<label class="switch">
<span>این فعالیت انجام شده</span>
<input v-model="isDone" type="checkbox" />
<i />
</label>
<label class="switch">
<span>این فعالیت نیاز به توضیح دارد</span>
<input v-model="needsNote" type="checkbox" />
<i />
</label>
</div>
<div class="card trio">
<div class="field">
<label>تاریخ <i>*</i></label>
<JalaliDateInput v-model="dueDate" required />
</div>
<div class="field">
<label>ساعت <i>*</i></label>
<JalaliDateInput v-model="dueTime" mode="time" required />
</div>
<div class="field">
<label>مدت فعالیت (دقیقه) <i>*</i></label>
<div class="stepper">
<button type="button" aria-label="افزایش" @click="bumpDuration(5)">
<UiIcon name="plus" :size="16" />
</button>
<strong>{{ toFaDigits(durationMinutes) }}</strong>
<button type="button" aria-label="کاهش" @click="bumpDuration(-5)">
<UiIcon name="minus" :size="16" />
</button>
</div>
</div>
</div>
<div class="card">
<div class="field">
<label>مسئولان انجام این فعالیت <i>*</i></label>
<button class="picker person-pick" type="button" @click.stop="toggleAssigneeMenu">
<div class="assignee-avatars">
<UserAvatar
v-for="user in selectedAssignees.slice(0, 3)"
:key="user.id"
:name="user.fullName"
:user-id="user.id"
:has-avatar="user.hasAvatar"
:size="28"
/>
<UserAvatar
v-if="!selectedAssignees.length"
name="مسئول"
:size="28"
/>
</div>
<span :class="{ ghost: !selectedAssignees.length }">
{{ assigneePickerLabel }}
</span>
<UiIcon name="chevron" :size="16" />
</button>
<div v-if="assigneeOpen" class="menu person-menu multi-menu" @click.stop>
<label class="person-search">
<UiIcon name="search" :size="15" />
<input
ref="assigneeSearchInput"
v-model="assigneeQuery"
type="search"
:placeholder="teamGroups.length ? 'جستجوی تیم، نقش یا نام عضو…' : 'جستجوی نقش یا نام عضو…'"
@keydown.esc="assigneeOpen = false"
/>
</label>
<ul class="person-list">
<template v-if="filteredRoleGroups.length">
<li class="menu-label">نقش‌ها</li>
<li v-for="role in filteredRoleGroups" :key="role.id">
<button
type="button"
class="multi-option"
:class="{ on: pickedRoleIds.includes(role.id) }"
@click="toggleRole(role.id, role.userIds)"
>
<span class="check" :class="{ on: pickedRoleIds.includes(role.id) }">
<UiIcon v-if="pickedRoleIds.includes(role.id)" name="check-plain" :size="12" />
</span>
<span class="role-name">{{ role.name }}</span>
<small class="role-count">{{ toFaDigits(role.userIds.length) }} نفر</small>
</button>
</li>
</template>
<template v-if="filteredTeamGroups.length">
<li class="menu-label">تیم‌ها</li>
<li v-for="team in filteredTeamGroups" :key="team.id">
<button
type="button"
class="multi-option"
:class="{ on: pickedTeamIds.includes(team.id) }"
@click="toggleTeam(team.id, team.userIds)"
>
<span class="check" :class="{ on: pickedTeamIds.includes(team.id) }">
<UiIcon v-if="pickedTeamIds.includes(team.id)" name="check-plain" :size="12" />
</span>
<span class="role-name">{{ team.name }}</span>
<small class="role-count">{{ toFaDigits(team.userIds.length) }} نفر</small>
</button>
</li>
</template>
<li v-if="(roleGroups.length || teamGroups.length) && filteredUsers.length" class="menu-label">اعضا</li>
<li v-for="user in filteredUsers" :key="user.id">
<button
type="button"
class="multi-option"
:class="{ on: assigneeIds.includes(user.id) }"
@click="toggleAssignee(user.id)"
>
<span class="check" :class="{ on: assigneeIds.includes(user.id) }">
<UiIcon v-if="assigneeIds.includes(user.id)" name="check-plain" :size="12" />
</span>
<UserAvatar :name="user.fullName" :user-id="user.id" :has-avatar="user.hasAvatar" :size="28" />
<span class="role-name">{{ user.fullName }}</span>
<small v-if="user.orgRoleName" class="role-count">{{ user.orgRoleName }}</small>
</button>
</li>
<li
v-if="assigneeSearch && !filteredRoleGroups.length && !filteredTeamGroups.length && !filteredUsers.length"
class="person-empty"
>
موردی پیدا نشد.
</li>
</ul>
</div>
<small class="hint">
{{
teamGroups.length
? 'یک تیم یا نقش را انتخاب کنید تا همهٔ اعضایش مسئول شوند، یا چند نفر را جدا انتخاب کنید.'
: 'یک نقش را انتخاب کنید تا همهٔ اعضای آن مسئول شوند، یا چند نفر را جدا انتخاب کنید.'
}}
</small>
<div v-if="selectedRoles.length || selectedTeams.length || extraAssignees.length" class="assignee-chips">
<span v-for="role in selectedRoles" :key="role.id" class="chip">
{{ role.name }}
<button type="button" aria-label="حذف نقش" @click="toggleRole(role.id, role.userIds)">×</button>
</span>
<span v-for="team in selectedTeams" :key="team.id" class="chip">
{{ team.name }}
<button type="button" aria-label="حذف تیم" @click="toggleTeam(team.id, team.userIds)">×</button>
</span>
<span v-for="user in extraAssignees" :key="user.id" class="chip">
{{ user.fullName }}
<button
v-if="assigneeIds.length > 1"
type="button"
aria-label="حذف مسئول"
@click="toggleAssignee(user.id)"
>
×
</button>
</span>
</div>
</div>
</div>
<div class="card">
<div class="field">
<div class="label-row">
<label>شخص / مشتری مرتبط <small class="optional">(اختیاری)</small></label>
<button type="button" class="person-add" @click.stop="openNewPerson">
<UiIcon name="user-plus" :size="14" />
افزودن شخص جدید
</button>
</div>
<button class="picker person-pick" type="button" @click.stop="togglePersonMenu">
<span class="person-ico" :class="{ on: selectedPerson }">
<UiIcon name="user" :size="16" />
</span>
<span class="who">
<b :class="{ ghost: !selectedPerson }">{{ selectedPerson?.fullName || 'بدون شخص — انتخاب کنید' }}</b>
<small v-if="personSub">{{ personSub }}</small>
</span>
<UiIcon name="chevron" :size="16" />
</button>
<div v-if="personOpen" class="menu person-menu" @click.stop>
<label class="person-search">
<UiIcon name="search" :size="15" />
<input
ref="personSearchInput"
v-model="personQuery"
type="search"
placeholder="جستجوی نام، موبایل یا کد…"
@keydown.esc="personOpen = false"
/>
</label>
<ul class="person-list">
<li v-if="personId">
<button type="button" class="person-row" @click="clearPerson">
<span class="row-text">
<b>بدون شخص</b>
<small>حذف شخص انتخاب‌شده</small>
</span>
</button>
</li>
<li v-for="p in personOptions" :key="p.id">
<button
type="button"
class="person-row"
:class="{ on: p.id === personId }"
@click="pickPerson(p)"
>
<span class="row-text">
<b>{{ p.fullName }}</b>
<small v-if="p.company?.name">{{ p.company.name }}</small>
</span>
<span v-if="p.mobile || p.phone" class="row-phone" dir="ltr">
{{ p.mobile || p.phone }}
</span>
<UiIcon v-if="p.id === personId" name="check-plain" :size="14" class="row-check" />
</button>
</li>
<li v-if="personSearching" class="person-empty">در حال جستجو…</li>
<li v-else-if="!personOptions.length" class="person-empty">
شخصی پیدا نشد.
<button
v-if="personQuery.trim()"
type="button"
class="empty-add"
@click="openNewPerson"
>
افزودن «{{ personQuery.trim() }}» به‌عنوان شخص جدید
</button>
</li>
</ul>
</div>
</div>
<div v-if="personDeals.length > 1" class="field deal-field">
<label>فرصت مرتبط</label>
<select v-model="dealId">
<option value="">بدون معامله</option>
<option v-for="d in personDeals" :key="d.id" :value="d.id">{{ d.title }}</option>
</select>
</div>
</div>
<div class="card">
<p class="pri-label">اولویت</p>
<div class="priorities">
<label v-for="opt in priorityOptions" :key="opt.value" :class="['pri', opt.tone, { on: priority === opt.value }]">
<input v-model="priority" type="radio" :value="opt.value" />
<UiIcon name="flag" :size="16" />
<span>{{ opt.label }}</span>
</label>
</div>
</div>
<div class="card reminder">
<JalaliDateInput
v-model="reminderAt"
mode="datetime"
icon="bell"
placeholder="یادآوری تنظیم نشده است"
/>
<div v-if="reminderAt" class="reminder-channels">
<p class="channel-title">چگونه یادآوری شود؟</p>
<label class="channel">
<input v-model="reminderNotifyApp" type="checkbox" />
<span>
<strong>اعلان داخل پنل</strong>
<small>در زنگوله اعلان‌های {{ brand.nameFa }} نشان داده می‌شود</small>
</span>
</label>
<label class="channel">
<input v-model="reminderNotifySms" type="checkbox" />
<span>
<strong>پیامک به مسئول</strong>
<small>به شماره موبایل مسئول فعالیت ارسال می‌شود</small>
</span>
</label>
<p
v-if="reminderAt && !reminderNotifyApp && !reminderNotifySms"
class="channel-warn"
>
حداقل یکی از روش‌های یادآوری را انتخاب کنید.
</p>
<p
v-if="reminderNotifySms && smsAssigneesWithoutMobile.length"
class="channel-warn"
>
شمارهٔ موبایل برای {{ smsAssigneesWithoutMobile.join('، ') }} ثبت نشده؛ پیامک برایشان ارسال نمی‌شود.
</p>
<p v-if="reminderSentLabel" class="channel-sent">
این یادآوری در {{ reminderSentLabel }} ارسال شد. برای یادآوری دوباره، زمان جدیدی انتخاب کنید.
</p>
</div>
</div>
</div>
<div ref="noteCard" class="card note-card">
<div class="note-head">
<label for="description">
توضیحات و یادداشت
<i v-if="needsNote" class="req-star">*</i>
</label>
<VoiceToTextButton
@transcript="(text) => (description = appendSpokenText(description, text))"
/>
</div>
<UiTextarea
id="description"
v-model="description"
:rows="4"
placeholder="جزئیات تماس، نتیجه جلسه یا یادداشت مرتبط را بنویسید…"
/>
<div class="attach-row">
<button
type="button"
class="icon-tool"
aria-label="پیوست"
title="پیوست"
:disabled="photoBusy || recording"
@click="fileInput?.click()"
>
<UiIcon name="clip" :size="16" />
</button>
<button
type="button"
class="icon-tool"
:class="{ recording }"
:disabled="voiceBusy"
:aria-label="recording ? `توقف ضبط پیام صوتی ${formatVoiceClock(recordSeconds)}` : 'ضبط پیام صوتی'"
:title="recording ? `توقف ضبط ${formatVoiceClock(recordSeconds)}` : 'ضبط پیام صوتی'"
@click="toggleRecording"
>
<UiIcon name="mic" :size="16" />
</button>
<span v-if="recording" class="rec-time">{{ formatVoiceClock(recordSeconds) }}</span>
<input
ref="fileInput"
class="photo-file"
type="file"
multiple
@change="onFilesPicked"
/>
<small class="hint">
عکس زیر ۲۰۰ کیلوبایت و صوت مثل پیام‌رسان کم‌حجم می‌شود. فایل‌های دیگر تا ۱۰ مگابایت؛ همه در سقف حجم حساب می‌شوند.
</small>
</div>
<p v-if="photoError" class="error">{{ photoError }}</p>
<div v-if="pendingPhotos.length || savedPhotos.length" class="photo-grid">
<figure v-for="photo in savedPhotos" :key="photo.id">
<img v-if="photo.previewUrl" :src="photo.previewUrl" :alt="photo.fileName" />
<button type="button" aria-label="حذف عکس" @click="removeSavedPhoto(photo)">×</button>
</figure>
<figure v-for="photo in pendingPhotos" :key="photo.localId">
<img :src="photo.dataUrl" :alt="photo.name" />
<button type="button" aria-label="حذف عکس" @click="removePendingPhoto(photo.localId)">×</button>
</figure>
</div>
<p v-if="voiceError" class="error">{{ voiceError }}</p>
<div v-if="pendingVoices.length || savedVoices.length" class="voice-list">
<div v-for="voice in savedVoices" :key="voice.id" class="voice-item">
<audio v-if="voice.previewUrl" :src="voice.previewUrl" controls preload="metadata" />
<span>{{ formatVoiceBytes(voice.sizeBytes) }}</span>
<button type="button" aria-label="حذف صوت" @click="removeSavedVoice(voice)">×</button>
</div>
<div v-for="voice in pendingVoices" :key="voice.localId" class="voice-item">
<audio :src="voice.url" controls preload="metadata" />
<span>{{ formatVoiceBytes(voice.file.size) }}</span>
<button type="button" aria-label="حذف صوت" @click="removePendingVoice(voice.localId)">×</button>
</div>
</div>
<ul v-if="pendingFiles.length || savedFiles.length" class="file-list">
<li v-for="file in savedFiles" :key="file.id">
<UiIcon name="document" :size="15" />
<span>{{ file.fileName }}</span>
<small>{{ formatVoiceBytes(file.sizeBytes) }}</small>
<button type="button" aria-label="حذف فایل" @click="removeSavedFile(file)">×</button>
</li>
<li v-for="file in pendingFiles" :key="file.localId">
<UiIcon name="document" :size="15" />
<span>{{ file.name }}</span>
<small>{{ formatVoiceBytes(file.file.size) }}</small>
<button type="button" aria-label="حذف فایل" @click="removePendingFile(file.localId)">×</button>
</li>
</ul>
<label for="activity-tags" class="tags-label">هشتگ / برچسب</label>
<UiInput
id="activity-tags"
v-model="tagsRaw"
placeholder="پیگیری، فوری (با کاما یا #)"
/>
<div v-if="tagPreview.length" class="tag-preview">
<span v-for="t in tagPreview" :key="t" class="tag-chip">#{{ t }}</span>
</div>
<small class="hint">
چند برچسب را با کاما (،) یا # جدا کنید؛ برچسب‌ها در جزئیات و لیست فعالیت‌ها نمایش داده می‌شوند و با
جستجوی همان کلمه پیدا می‌شوند.
</small>
</div>
<p v-if="error" class="error">{{ error }}</p>
</form>
<aside class="summary">
<h2>
<UiIcon name="document" :size="18" />
خلاصه فعالیت
</h2>
<div v-if="!summaryReady" class="empty">
<UiIcon name="calendar" :size="28" />
<p>با تکمیل اطلاعات، خلاصه فعالیت در اینجا نمایش داده می‌شود.</p>
</div>
<dl>
<div>
<dt>نوع فعالیت</dt>
<dd>{{ typeMeta?.name || '—' }}</dd>
</div>
<div>
<dt>تاریخ و ساعت</dt>
<dd>{{ duePreview }}</dd>
</div>
<div>
<dt>مدت فعالیت</dt>
<dd>{{ toFaDigits(durationMinutes) }} دقیقه</dd>
</div>
<div>
<dt>مسئولان</dt>
<dd>
{{
assigneeSummaryLabel
}}
</dd>
</div>
<div>
<dt>مشتری مرتبط</dt>
<dd>{{ selectedPerson?.fullName || '—' }}</dd>
</div>
<div>
<dt>وضعیت انجام</dt>
<dd>
<UiBadge :variant="isDone ? 'success' : 'neutral'" size="sm">
{{ statusText }}
</UiBadge>
</dd>
</div>
<div>
<dt>اولویت</dt>
<dd>
<UiBadge
:variant="priority === 'very_high' || priority === 'high' ? 'danger' : priority === 'medium' ? 'warning' : 'neutral'"
size="sm"
>
{{ priorityText }}
</UiBadge>
</dd>
</div>
</dl>
</aside>
</div>
<footer v-if="!embedded" class="mobile-save">
<UiButton
variant="primary"
type="button"
:disabled="saving || loading"
:loading="saving"
@click="submit('detail')"
>
{{ saving ? 'در حال ذخیره…' : 'ذخیره فعالیت' }}
</UiButton>
</footer>
<!-- Embedded Modal Actions (Fixed at bottom) -->
<div v-if="embedded" class="embed-actions">
<UiButton
variant="primary"
type="button"
:disabled="saving"
:loading="saving"
@click="submit('detail')"
>
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : 'ذخیره فعالیت' }}
</UiButton>
<UiButton variant="ghost" type="button" :disabled="saving" @click="cancel">انصراف</UiButton>
</div>
<UiModal :open="newPersonOpen" title="شخص جدید" nest @close="newPersonOpen = false">
<PersonFormView
v-if="newPersonOpen"
embedded
:initial-query="personQuery"
@saved="onPersonCreated"
@cancel="newPersonOpen = false"
/>
</UiModal>
</section>
</template>
<style scoped>
.crm-page:not(.embedded) {
padding-bottom: 5rem;
}
.crm-page.embedded {
padding-bottom: 0;
}
.embed-actions {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
}
.crumb {
margin: 0;
display: flex;
align-items: center;
gap: var(--sp-2);
color: var(--color-text-muted);
font-size: var(--fs-xs);
}
.crumb a {
color: var(--color-primary-dark);
font-weight: 700;
text-decoration: none;
}
.tag-preview {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.tag-chip {
background: #f0fdfa;
color: #0f766e;
border-radius: 999px;
padding: 0.15rem 0.55rem;
font-size: 0.78rem;
font-weight: 600;
}
.req-star {
color: var(--color-danger);
font-style: normal;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-3);
}
.summary {
display: none;
}
.form,
.stack {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-3);
}
.card {
position: relative;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--sp-4);
box-shadow: var(--shadow-sm);
}
.field {
display: grid;
gap: var(--sp-1);
min-width: 0;
}
.field label,
.pri-label {
margin: 0;
color: var(--color-text-muted);
font-size: var(--fs-xs);
font-weight: 600;
}
.field i {
color: var(--color-danger);
font-style: normal;
}
.field input,
.field select,
.field textarea,
.note-card textarea {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
min-height: var(--control-h);
padding: 0 0.85rem;
font: inherit;
background: #fff;
box-sizing: border-box;
}
.field textarea,
.note-card textarea {
min-height: 5.5rem;
padding: 0.65rem 0.85rem;
}
.picker {
width: 100%;
min-height: var(--control-h);
height: var(--control-h);
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.7rem;
font: inherit;
text-align: start;
cursor: pointer;
}
.picker .ghost,
.who .ghost {
color: #94a3b8;
font-weight: 500;
}
.picker :deep(.ico:last-child) {
margin-inline-start: auto;
color: #94a3b8;
transform: rotate(90deg);
}
.assignee-avatars {
display: flex;
align-items: center;
}
.assignee-avatars :deep(.avatar),
.assignee-avatars > * {
margin-inline-end: -0.35rem;
box-shadow: 0 0 0 2px #fff;
}
.hint {
display: block;
margin-top: 0.35rem;
color: #94a3b8;
font-size: 0.75rem;
}
.assignee-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.5rem;
}
.assignee-chips .chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: #f0fdfa;
color: #0f766e;
border-radius: 999px;
padding: 0.2rem 0.55rem;
font-size: 0.78rem;
font-weight: 600;
}
.assignee-chips .chip button {
border: 0;
background: transparent;
color: #64748b;
cursor: pointer;
font: inherit;
line-height: 1;
padding: 0 0.1rem;
}
.menu-label {
padding: 0.45rem 0.75rem 0.15rem;
color: #64748b;
font-size: 0.72rem;
font-weight: 700;
}
.multi-menu .role-name {
min-width: 0;
}
.multi-menu .role-count {
margin-inline-start: auto;
color: #94a3b8;
font-size: 0.72rem;
}
.multi-menu .multi-option {
display: flex;
align-items: center;
gap: 0.55rem;
width: 100%;
}
.multi-menu .check {
width: 18px;
height: 18px;
border: 1.5px solid #cbd5e1;
border-radius: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
}
.multi-menu .check.on {
background: #14b8a6;
border-color: #14b8a6;
}
.multi-menu .multi-option.on {
background: #f0fdfa;
}
.type-ico {
width: 32px;
height: 32px;
border-radius: 999px;
display: grid;
place-items: center;
background: #ecfdf5;
color: #0d9488;
flex-shrink: 0;
}
.type-ico.sm {
width: 26px;
height: 26px;
}
.menu button.on {
background: #f0fdfa;
}
.type-fields-count {
margin-inline-start: auto;
color: #94a3b8;
font-size: 0.72rem;
}
.menu-empty {
padding: 0.6rem;
color: #94a3b8;
font-size: 0.82rem;
}
.type-fields {
display: grid;
gap: 0.85rem;
}
.type-fields-title {
margin: 0;
display: flex;
align-items: center;
gap: 0.45rem;
font-weight: 700;
font-size: 0.92rem;
color: #0f172a;
}
.who {
display: grid;
min-width: 0;
text-align: start;
}
.who b {
font-weight: 600;
}
.who small {
color: #94a3b8;
font-size: 0.75rem;
}
.menu {
position: absolute;
inset-inline: 0.95rem;
top: calc(100% - 0.35rem);
z-index: 5;
margin: 0;
padding: 0.35rem;
list-style: none;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
max-height: 16rem;
overflow: auto;
}
.menu button {
width: 100%;
display: flex;
align-items: center;
gap: 0.55rem;
border: 0;
background: none;
padding: 0.55rem 0.5rem;
border-radius: var(--radius);
font: inherit;
text-align: start;
cursor: pointer;
}
.menu button:hover {
background: #f8fafc;
}
.label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.label-row .optional {
color: #94a3b8;
font-weight: 400;
font-size: 0.75rem;
}
.person-add {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 0;
background: none;
padding: 0.15rem 0.35rem;
border-radius: 6px;
font: inherit;
font-size: var(--fs-xs);
font-weight: 700;
color: #0f766e;
cursor: pointer;
}
.person-add:hover {
background: #f0fdfa;
}
.person-ico {
width: 32px;
height: 32px;
border-radius: 999px;
display: grid;
place-items: center;
flex-shrink: 0;
background: #f1f5f9;
color: #94a3b8;
}
.person-ico.on {
background: #ccfbf1;
color: #0f766e;
}
.person-menu {
max-height: none;
overflow: visible;
display: grid;
gap: 0.35rem;
padding: 0.45rem;
z-index: 30;
border: 1px solid #e2e8f0;
}
.person-search {
display: flex;
align-items: center;
gap: 0.45rem;
height: 2.4rem;
padding: 0 0.7rem;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
color: #94a3b8;
}
.person-search:focus-within {
border-color: #5eead4;
}
.person-search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
font-size: 0.88rem;
color: #0f172a;
}
.person-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 15rem;
overflow-y: auto;
}
.menu .person-row {
gap: 0.6rem;
padding: 0.5rem 0.6rem;
border-radius: 8px;
}
.menu .person-row.on {
background: #f0fdfa;
}
.row-text {
flex: 1;
min-width: 0;
display: grid;
gap: 0.1rem;
}
.row-text b {
font-size: 0.88rem;
font-weight: 600;
color: #0f172a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-text small {
font-size: 0.74rem;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-phone {
flex-shrink: 0;
font-size: 0.78rem;
color: #64748b;
font-variant-numeric: tabular-nums;
}
.row-check {
flex-shrink: 0;
color: #0d9488;
}
.person-list li + li {
border-top: 1px solid #f8fafc;
}
.person-empty {
padding: 0.8rem;
display: grid;
gap: 0.5rem;
justify-items: center;
text-align: center;
color: #94a3b8;
font-size: 0.85rem;
}
.menu .empty-add {
width: auto;
justify-content: center;
border: 1px dashed #5eead4;
border-radius: 999px;
padding: 0.4rem 0.9rem;
color: #0f766e;
font-weight: 700;
font-size: 0.8rem;
}
.menu .empty-add:hover {
background: #f0fdfa;
}
.toggles {
display: grid;
gap: 0.85rem;
}
.switch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
cursor: pointer;
}
.switch input {
display: none;
}
.switch i {
width: 44px;
height: 26px;
border-radius: 999px;
background: #e2e8f0;
position: relative;
flex-shrink: 0;
}
.switch i::after {
content: '';
position: absolute;
inset-inline-start: 3px;
top: 3px;
width: 20px;
height: 20px;
border-radius: 999px;
background: #fff;
transition: 0.18s;
}
.switch input:checked + i {
background: #14b8a6;
}
.switch input:checked + i::after {
inset-inline-start: 21px;
}
.trio {
display: grid;
gap: 0.85rem;
}
.stepper {
display: flex;
align-items: center;
justify-content: space-between;
min-height: var(--control-h);
height: var(--control-h);
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0.15rem;
}
.stepper button {
width: 28px;
height: 28px;
border: 0;
border-radius: var(--radius);
background: #f0fdfa;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.stepper strong {
font-size: 1.05rem;
}
.deal-field {
margin-top: 0.75rem;
}
.priorities {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.45rem;
margin-top: 0.45rem;
min-width: 0;
}
.pri {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 0.28rem;
font-size: 0.72rem;
color: #64748b;
cursor: pointer;
white-space: nowrap;
flex: 0 0 auto;
}
.pri input {
accent-color: #14b8a6;
width: 13px;
height: 13px;
margin: 0;
flex-shrink: 0;
}
.pri.very-high {
color: #dc2626;
}
.pri.high {
color: #ea580c;
}
.pri.medium {
color: #0d9488;
}
.pri.low {
color: #2563eb;
}
.pri.very-low {
color: #94a3b8;
}
.pri.on span {
font-weight: 600;
}
.reminder :deep(.trigger) {
border: 0;
min-height: var(--control-h);
height: var(--control-h);
padding-inline: 0.15rem;
}
.reminder-channels {
display: grid;
gap: 0.55rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px dashed #e2e8f0;
}
.channel-title {
margin: 0;
font-size: 0.88rem;
font-weight: 600;
color: #0f172a;
}
.channel {
display: flex;
align-items: flex-start;
gap: 0.55rem;
cursor: pointer;
padding: 0.55rem 0.65rem;
border-radius: var(--radius);
border: 1px solid #e2e8f0;
background: #f8fafc;
}
.channel:has(input:checked) {
border-color: #99f6e4;
background: #f0fdfa;
}
.channel input {
margin-top: 0.2rem;
accent-color: #0d9488;
}
.channel span {
display: grid;
gap: 0.15rem;
min-width: 0;
}
.channel strong {
font-size: 0.9rem;
color: #0f172a;
}
.channel small {
color: #64748b;
font-size: 0.78rem;
line-height: 1.45;
}
.channel-warn {
margin: 0;
color: #b45309;
font-size: 0.8rem;
}
.channel-sent {
margin: 0;
color: #0f766e;
font-size: 0.8rem;
}
.note-card {
display: grid;
gap: 0.45rem;
}
.note-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.note-card textarea {
min-height: 7rem;
resize: vertical;
}
.attach-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
}
.icon-tool {
width: 36px;
height: 36px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
color: #0f766e;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.icon-tool.recording {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.icon-tool:disabled {
opacity: 0.6;
cursor: progress;
}
.rec-time {
color: #b91c1c;
font-size: 0.82rem;
font-weight: 700;
}
.file-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.file-list li {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.file-list span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-list small {
color: var(--muted);
white-space: nowrap;
}
.file-list button {
margin-inline-start: auto;
width: 1.4rem;
height: 1.4rem;
border: 0;
border-radius: 999px;
background: #e2e8f0;
cursor: pointer;
}
.photo-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
}
.photo-add {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px dashed #99f6e4;
background: #f0fdfa;
color: #0f766e;
border-radius: var(--radius);
padding: 0.35rem 0.7rem;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.photo-add:disabled {
opacity: 0.7;
cursor: progress;
}
.photo-add.recording {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.voice-list {
display: grid;
gap: 0.45rem;
}
.voice-item {
display: flex;
align-items: center;
gap: 0.45rem;
}
.voice-item audio {
flex: 1;
min-width: 0;
height: 2.25rem;
}
.voice-item span {
color: var(--muted);
font-size: 0.78rem;
white-space: nowrap;
}
.voice-item button {
width: 1.4rem;
height: 1.4rem;
border: 0;
border-radius: 999px;
background: #e2e8f0;
cursor: pointer;
}
.photo-file {
display: none;
}
.photo-grid {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.photo-grid figure {
position: relative;
margin: 0;
width: 4.5rem;
height: 4.5rem;
}
.photo-grid img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.6rem;
border: 1px solid #e2e8f0;
}
.photo-grid figure button {
position: absolute;
top: 0.15rem;
inset-inline-end: 0.15rem;
width: 1.2rem;
height: 1.2rem;
border: 0;
border-radius: 999px;
background: rgba(15, 23, 42, 0.72);
color: #fff;
cursor: pointer;
line-height: 1;
}
.muted,
.error {
margin: 0;
}
.error {
color: #b91c1c;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: var(--control-h);
border-radius: var(--radius);
padding: 0 0.95rem;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.btn.primary {
border: 0;
background: #14b8a6;
color: #fff;
}
.btn.outline {
border: 1.5px solid #14b8a6;
background: #fff;
color: #0f766e;
}
.btn.cancel {
border: 1.5px solid #f87171;
background: #fff;
color: #dc2626;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mobile-save {
position: fixed;
inset-inline: 0.85rem;
bottom: calc(0.55rem + var(--mobile-tabbar-h));
z-index: 8;
}
.mobile-save .btn {
width: 100%;
min-height: var(--control-h);
height: var(--control-h);
border-radius: var(--radius);
}
@media (min-width: 960px) {
.crm-page:not(.embedded),
.page {
padding-bottom: 1rem;
gap: 1rem;
}
.desktop-head {
display: flex;
}
.mobile-head,
.mobile-save {
display: none;
}
.layout {
grid-template-columns: minmax(0, 1fr) minmax(240px, 280px);
align-items: start;
gap: 1rem;
}
.summary {
display: block;
position: sticky;
top: 0.8rem;
background: #fff;
border-radius: var(--radius);
padding: 1rem 1.05rem 0.7rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
}
.summary h2 {
margin: 0 0 0.8rem;
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.95rem;
color: #0f766e;
}
.empty {
display: grid;
justify-items: center;
gap: 0.45rem;
padding: 1rem 0.6rem;
border-radius: var(--radius);
background: #f8fafc;
color: #94a3b8;
text-align: center;
font-size: 0.78rem;
margin-bottom: 0.7rem;
}
.empty p {
margin: 0;
}
.summary dl {
margin: 0;
}
.summary dl > div {
display: flex;
justify-content: space-between;
gap: 0.6rem;
padding: 0.62rem 0;
border-top: 1px solid #f1f5f9;
font-size: 0.8rem;
}
.summary dt {
color: #94a3b8;
}
.summary dd {
margin: 0;
font-weight: 700;
}
.pair,
.toggles,
.trio {
grid-template-columns: 1fr 1fr;
align-items: end;
}
.trio {
grid-template-columns: 1fr 1fr 1fr;
}
.toggles {
align-items: center;
}
.toggles .switch {
justify-content: flex-start;
}
.priorities {
justify-content: flex-start;
gap: 1.15rem;
}
.pri {
gap: 0.32rem;
}
}
</style>