1224 lines
31 KiB
Vue
1224 lines
31 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
|
import { api } from '@/lib/api';
|
|
import {
|
|
calendarDaysBetween,
|
|
formatClockFa,
|
|
formatJalaliDay,
|
|
formatJalaliNumeric,
|
|
toFaDigits,
|
|
} from '@/lib/jalali';
|
|
import UiIcon from '@/components/UiIcon.vue';
|
|
import UserAvatar from '@/components/UserAvatar.vue';
|
|
import UserMultiSelect from '@/components/UserMultiSelect.vue';
|
|
import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
|
|
import ActivityPostponeSheet from '@/components/activities/ActivityPostponeSheet.vue';
|
|
import ActivityFieldsView from '@/components/activities/ActivityFieldsView.vue';
|
|
import type {
|
|
ActivityChecklistItem,
|
|
ActivityDto,
|
|
ActivityPriority,
|
|
OrgUserDto,
|
|
} from '@mento/shared';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import { canAssignToOthers } from '@/lib/assign-access';
|
|
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
|
import { brand } from '@mento/shared';
|
|
import {
|
|
leadNoteFileIsImage,
|
|
noteChips,
|
|
parseLeadFormNotes,
|
|
type LeadNoteFile,
|
|
type LeadNoteRow,
|
|
} from '@/lib/lead-form-notes';
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
|
|
const ACTIVITY_LIST_QUERY = ['board', 'view', 'desk', 'filter', 'stage', 'q', 'typeDefId', 'status', 'assigneeId', 'title', 'customer', 'due'] as const;
|
|
const NAV_API_QUERY = ['q', 'desk', 'type', 'typeDefId', 'status', 'assigneeId', 'title', 'customer', 'due'] as const;
|
|
|
|
function activitiesListLocation() {
|
|
const query: Record<string, string> = {};
|
|
for (const key of ACTIVITY_LIST_QUERY) {
|
|
const value = route.query[key];
|
|
if (typeof value === 'string' && value) query[key] = value;
|
|
}
|
|
return { name: 'activities' as const, query };
|
|
}
|
|
|
|
function goBack() {
|
|
const back = (window.history.state as { back?: string | null } | null)?.back ?? '';
|
|
if (back.split('?')[0] === '/activities') {
|
|
router.back();
|
|
return;
|
|
}
|
|
void router.push(activitiesListLocation());
|
|
}
|
|
const auth = useAuthStore();
|
|
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
|
const canReassign = computed(() => canAssignToOthers(auth.user, 'activities'));
|
|
|
|
const loading = ref(true);
|
|
const saving = ref(false);
|
|
const error = ref('');
|
|
const activity = ref<ActivityDto | null>(null);
|
|
const imageUrls = ref<Array<{ id: string; name: string; url: string; kind: 'image' | 'audio' | 'file' }>>([]);
|
|
const moreOpen = ref(false);
|
|
const commentOpen = ref(false);
|
|
const postponeOpen = ref(false);
|
|
const noteOpen = ref(true);
|
|
const noteModel = computed(() => parseLeadFormNotes(activity.value?.description));
|
|
const noteRows = computed(() =>
|
|
noteModel.value.mode === 'answers' ? noteModel.value.rows : ([] as LeadNoteRow[]),
|
|
);
|
|
const noteExtra = computed(() =>
|
|
noteChips(
|
|
noteModel.value.mode === 'answers' ? noteModel.value.prose : activity.value?.description,
|
|
).filter(
|
|
(chip) => !(chip.kind === 'text' && chip.text.replace(/\s+/g, '').trim() === 'پاسخهایفرم:'),
|
|
),
|
|
);
|
|
const recoveredImages = ref<Record<string, string>>({});
|
|
|
|
function noteImageSrc(file: LeadNoteFile): string | null {
|
|
if (!leadNoteFileIsImage(file)) return null;
|
|
return file.dataUrl || recoveredImages.value[file.name] || null;
|
|
}
|
|
|
|
watch(
|
|
[() => activity.value?.id, () => activity.value?.lead?.id, noteRows],
|
|
async ([activityId, leadId]) => {
|
|
const missing = noteRows.value
|
|
.map((row) => row.file)
|
|
.filter((file): file is LeadNoteFile => Boolean(file && !file.dataUrl && leadNoteFileIsImage(file)))
|
|
.map((file) => file.name);
|
|
recoveredImages.value = {};
|
|
if (!activityId || !leadId || !missing.length) return;
|
|
try {
|
|
const { data } = await api.get<{ notes?: string | null }>(`/leads/${leadId}`);
|
|
if (activity.value?.id !== activityId) return;
|
|
const wanted = new Set(missing);
|
|
const next: Record<string, string> = {};
|
|
for (const row of parseLeadFormNotes(data.notes).rows) {
|
|
const file = row.file;
|
|
if (file?.dataUrl && wanted.has(file.name) && leadNoteFileIsImage(file)) {
|
|
next[file.name] = file.dataUrl;
|
|
}
|
|
}
|
|
recoveredImages.value = next;
|
|
} catch {
|
|
recoveredImages.value = {};
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
const users = ref<OrgUserDto[]>([]);
|
|
const assignUserIds = ref<string[]>([]);
|
|
|
|
const assigneeNames = computed(() => {
|
|
const a = activity.value;
|
|
if (!a) return '—';
|
|
const list = a.assignees?.length
|
|
? a.assignees.map((u) => u.fullName)
|
|
: a.assignee
|
|
? [a.assignee.fullName]
|
|
: [];
|
|
return list.length ? list.join('، ') : '—';
|
|
});
|
|
|
|
const activityTypes = useActivityTypes();
|
|
const typeView = computed(() => (activity.value ? activityTypeView(activity.value) : null));
|
|
const typeDef = computed(() => (activity.value ? activityTypes.defOf(activity.value) : null));
|
|
const fieldsBusy = ref(false);
|
|
|
|
/** تیک چکلیست بدون باز کردن فرم ویرایش */
|
|
async function toggleChecklistItem(key: string, itemId: string, done: boolean) {
|
|
const a = activity.value;
|
|
if (!a || fieldsBusy.value) return;
|
|
const list = (a.fields?.[key] as ActivityChecklistItem[] | undefined) ?? [];
|
|
const nextFields = {
|
|
...(a.fields ?? {}),
|
|
[key]: list.map((it) => (it.id === itemId ? { ...it, done } : it)),
|
|
};
|
|
activity.value = { ...a, fields: nextFields };
|
|
fieldsBusy.value = true;
|
|
error.value = '';
|
|
try {
|
|
const { data } = await api.patch<ActivityDto>(`/activities/${a.id}`, { fields: nextFields });
|
|
activity.value = { ...data, participantUsers: a.participantUsers };
|
|
} catch {
|
|
activity.value = a;
|
|
error.value = 'ثبت تغییر چکلیست ناموفق بود.';
|
|
} finally {
|
|
fieldsBusy.value = false;
|
|
}
|
|
}
|
|
|
|
const priorityLabel: Record<ActivityPriority, string> = {
|
|
very_low: 'خیلی کم',
|
|
low: 'کم',
|
|
medium: 'متوسط',
|
|
high: 'زیاد',
|
|
very_high: 'خیلی زیاد',
|
|
};
|
|
|
|
const subtitle = computed(() => {
|
|
const a = activity.value;
|
|
if (!a) return '';
|
|
return a.deal?.title || a.company?.name || a.person?.fullName || '';
|
|
});
|
|
|
|
const dueLabel = computed(() => {
|
|
const due = activity.value?.dueAt;
|
|
if (!due) return 'بدون موعد';
|
|
const d = new Date(due);
|
|
const days = calendarDaysBetween(d, new Date());
|
|
const clock = formatClockFa(d);
|
|
if (days === 0) return `امروز - ${clock}`;
|
|
if (days === 1) return `دیروز - ${clock}`;
|
|
if (days === -1) return `فردا - ${clock}`;
|
|
return `${formatJalaliDay(d)} - ${clock}`;
|
|
});
|
|
|
|
const createdLabel = computed(() => {
|
|
const raw = activity.value?.createdAt;
|
|
if (!raw) return '—';
|
|
const d = new Date(raw);
|
|
return `${formatJalaliDay(d)} - ${formatClockFa(d)}`;
|
|
});
|
|
|
|
const isDone = computed(() => activity.value?.status === 'done');
|
|
const statusText = computed(() => {
|
|
if (!activity.value) return '—';
|
|
if (activity.value.status === 'done') return 'انجام شد';
|
|
if (activity.value.status === 'canceled') return 'لغو شده';
|
|
return 'انجام نشده';
|
|
});
|
|
|
|
async function load() {
|
|
if (!id.value) return;
|
|
loading.value = true;
|
|
error.value = '';
|
|
moreOpen.value = false;
|
|
try {
|
|
const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
|
|
activity.value = data;
|
|
await loadActivityImages(data);
|
|
assignUserIds.value = data.assignees?.length
|
|
? data.assignees.map((u) => u.id)
|
|
: data.assignee?.id
|
|
? [data.assignee.id]
|
|
: [];
|
|
} catch {
|
|
error.value = 'بارگذاری جزئیات فعالیت ناموفق بود.';
|
|
activity.value = null;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function clearImageUrls() {
|
|
for (const image of imageUrls.value) URL.revokeObjectURL(image.url);
|
|
imageUrls.value = [];
|
|
}
|
|
|
|
async function loadActivityImages(row: ActivityDto) {
|
|
clearImageUrls();
|
|
const next: Array<{ id: string; name: string; url: string; kind: 'image' | 'audio' | 'file' }> = [];
|
|
for (const image of row.images ?? []) {
|
|
const mime = image.mimeType.toLowerCase();
|
|
const kind = mime.startsWith('audio/') ? 'audio' : mime.startsWith('image/') ? 'image' : 'file';
|
|
try {
|
|
const { data } = await api.get<Blob>(`/activities/${row.id}/images/${image.id}`, {
|
|
responseType: 'blob',
|
|
});
|
|
next.push({
|
|
id: image.id,
|
|
name: image.fileName,
|
|
url: URL.createObjectURL(data),
|
|
kind,
|
|
});
|
|
} catch {
|
|
/* فایل در دسترس نیست */
|
|
}
|
|
}
|
|
imageUrls.value = next;
|
|
}
|
|
|
|
onUnmounted(clearImageUrls);
|
|
|
|
async function loadUsers() {
|
|
if (!canReassign.value) return;
|
|
try {
|
|
const { data } = await api.get<OrgUserDto[]>('/users');
|
|
users.value = data;
|
|
} catch {
|
|
users.value = [];
|
|
}
|
|
}
|
|
|
|
async function complete() {
|
|
if (!id.value || isDone.value) return;
|
|
saving.value = true;
|
|
error.value = '';
|
|
try {
|
|
await api.patch(`/activities/${id.value}/complete`);
|
|
await router.push({ name: 'activities' });
|
|
} catch {
|
|
error.value = 'ثبت انجامشدن ناموفق بود.';
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function reopen() {
|
|
if (!id.value || !activity.value || activity.value.status === 'open') return;
|
|
saving.value = true;
|
|
error.value = '';
|
|
try {
|
|
await api.patch(`/activities/${id.value}`, { status: 'open' });
|
|
await load();
|
|
} catch {
|
|
error.value = 'بازگشایی کار ناموفق بود.';
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function reassign() {
|
|
if (!id.value || !canReassign.value) return;
|
|
if (!assignUserIds.value.length) {
|
|
error.value = 'حداقل یک مسئول لازم است.';
|
|
return;
|
|
}
|
|
saving.value = true;
|
|
error.value = '';
|
|
try {
|
|
await api.patch(`/activities/${id.value}`, {
|
|
assigneeIds: [...assignUserIds.value],
|
|
assigneeId: assignUserIds.value[0] || null,
|
|
});
|
|
await load();
|
|
} catch {
|
|
error.value = 'ارجاع کار ناموفق بود.';
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function postponeTo(iso: string) {
|
|
if (!id.value || !activity.value || isDone.value) return;
|
|
saving.value = true;
|
|
error.value = '';
|
|
try {
|
|
await api.patch(`/activities/${id.value}`, { dueAt: iso });
|
|
postponeOpen.value = false;
|
|
await load();
|
|
} catch {
|
|
error.value = 'تعویق فعالیت ناموفق بود.';
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function submitComment(payload: { text: string; photoOnly: boolean }) {
|
|
const body = payload.text.trim() || (payload.photoOnly ? 'عکس پیوست شد.' : '');
|
|
if (!id.value || !activity.value || !body) return;
|
|
saving.value = true;
|
|
error.value = '';
|
|
try {
|
|
const prev = activity.value.description?.trim();
|
|
const now = new Date();
|
|
const author = auth.user?.fullName?.trim();
|
|
const stamp = `[${formatJalaliNumeric(now)} ساعت ${formatClockFa(now)}${author ? ` — ${author}` : ''}]`;
|
|
const entry = `${stamp}\n${body}`;
|
|
const next = prev ? `${prev}\n\n${entry}` : entry;
|
|
await api.patch(`/activities/${id.value}`, { description: next });
|
|
commentOpen.value = false;
|
|
await load();
|
|
} catch (e) {
|
|
error.value = 'ثبت نظر ناموفق بود.';
|
|
throw e;
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function remove() {
|
|
moreOpen.value = false;
|
|
if (!id.value || !confirm('این فعالیت حذف شود؟')) return;
|
|
try {
|
|
await api.delete(`/activities/${id.value}`);
|
|
await router.push({ name: 'activities' });
|
|
} catch {
|
|
error.value = 'حذف ناموفق بود.';
|
|
}
|
|
}
|
|
|
|
const navIds = ref<string[]>([]);
|
|
const deskBrowse = computed(
|
|
() => route.query.from === 'desk' && typeof route.query.nav === 'string' && route.query.nav.length > 0,
|
|
);
|
|
const navIndex = computed(() => navIds.value.indexOf(id.value));
|
|
const prevNavId = computed(() => (navIndex.value > 0 ? navIds.value[navIndex.value - 1]! : ''));
|
|
const nextNavId = computed(() =>
|
|
navIndex.value >= 0 && navIndex.value < navIds.value.length - 1
|
|
? navIds.value[navIndex.value + 1]!
|
|
: '',
|
|
);
|
|
const showNav = computed(() => deskBrowse.value && navIndex.value >= 0);
|
|
|
|
function navStorageKey(token: string) {
|
|
return `activity-nav:${token}`;
|
|
}
|
|
|
|
function readNavCache(token: string): string[] | null {
|
|
try {
|
|
const raw = sessionStorage.getItem(navStorageKey(token));
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw) as { ids?: unknown };
|
|
if (!Array.isArray(parsed.ids) || parsed.ids.some((item) => typeof item !== 'string')) return null;
|
|
return parsed.ids as string[];
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
let navSeq = 0;
|
|
async function loadNav() {
|
|
const seq = ++navSeq;
|
|
const token = typeof route.query.nav === 'string' ? route.query.nav : '';
|
|
const columnMissing =
|
|
route.query.view === 'board' &&
|
|
(typeof route.query.stage !== 'string' || !route.query.stage || route.query.stage === 'all');
|
|
if (!deskBrowse.value || !token || columnMissing) {
|
|
navIds.value = [];
|
|
return;
|
|
}
|
|
const cached = readNavCache(token);
|
|
if (cached) {
|
|
if (seq === navSeq) navIds.value = cached;
|
|
return;
|
|
}
|
|
const params: Record<string, string> = {
|
|
filter: typeof route.query.filter === 'string' && route.query.filter ? route.query.filter : 'today',
|
|
};
|
|
for (const key of NAV_API_QUERY) {
|
|
const value = route.query[key];
|
|
if (typeof value === 'string' && value) params[key] = value;
|
|
}
|
|
if (typeof route.query.board === 'string' && route.query.board) params.boardId = route.query.board;
|
|
if (typeof route.query.stage === 'string' && route.query.stage && route.query.stage !== 'all') {
|
|
params.stageId = route.query.stage;
|
|
}
|
|
try {
|
|
const { data } = await api.get<{ ids: string[] }>('/activities/nav-ids', { params });
|
|
if (seq !== navSeq) return;
|
|
const ids = Array.isArray(data.ids) ? data.ids : [];
|
|
try {
|
|
sessionStorage.setItem(navStorageKey(token), JSON.stringify({ ids }));
|
|
} catch {
|
|
/* حالت خصوصی مرورگر */
|
|
}
|
|
navIds.value = ids;
|
|
} catch {
|
|
if (seq === navSeq) navIds.value = [];
|
|
}
|
|
}
|
|
|
|
function goNeighbor(targetId: string) {
|
|
if (!targetId || targetId === id.value) return;
|
|
commentOpen.value = false;
|
|
postponeOpen.value = false;
|
|
moreOpen.value = false;
|
|
void router.push({ name: 'activity-detail', params: { id: targetId }, query: route.query });
|
|
}
|
|
|
|
watch(id, () => {
|
|
commentOpen.value = false;
|
|
postponeOpen.value = false;
|
|
void load();
|
|
});
|
|
watch(() => route.query.nav, () => void loadNav(), { immediate: true });
|
|
onMounted(() => {
|
|
void load();
|
|
void loadUsers();
|
|
void activityTypes.load();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<section class="page" @click="moreOpen = false">
|
|
<header class="nav">
|
|
<a class="icon-btn back" href="/activities" aria-label="بازگشت" @click.prevent="goBack">
|
|
<UiIcon name="chevron" :size="18" />
|
|
</a>
|
|
<span class="brand">{{ brand.name }}</span>
|
|
<div class="more" @click.stop>
|
|
<button class="icon-btn" type="button" aria-label="منوی بیشتر" @click="moreOpen = !moreOpen">
|
|
<UiIcon name="dots-v" :size="18" />
|
|
</button>
|
|
<div v-if="moreOpen && activity" class="menu">
|
|
<RouterLink :to="{ name: 'activity-edit', params: { id } }">ویرایش</RouterLink>
|
|
<button type="button" class="danger" @click="remove">حذف</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<h1>جزئیات فعالیت</h1>
|
|
<nav v-if="showNav" class="neighbors" aria-label="فعالیتهای همین فهرست">
|
|
<button type="button" class="neighbor" :disabled="!prevNavId" @click="goNeighbor(prevNavId)">
|
|
قبلی
|
|
</button>
|
|
<span>{{ toFaDigits(navIndex + 1) }} از {{ toFaDigits(navIds.length) }}</span>
|
|
<button type="button" class="neighbor" :disabled="!nextNavId" @click="goNeighbor(nextNavId)">
|
|
بعدی
|
|
</button>
|
|
</nav>
|
|
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
|
<p v-else-if="error && !activity" class="error">{{ error }}</p>
|
|
|
|
<template v-else-if="activity">
|
|
<article class="card">
|
|
<header class="hero">
|
|
<div>
|
|
<h2>
|
|
{{ activity.title }}
|
|
<UiIcon name="star" :size="16" class="star" />
|
|
</h2>
|
|
<p v-if="subtitle" class="sub">
|
|
<UiIcon name="building" :size="14" />
|
|
{{ subtitle }}
|
|
</p>
|
|
<div v-if="activity.tags?.length" class="tags">
|
|
<RouterLink
|
|
v-for="t in activity.tags"
|
|
:key="t"
|
|
class="tag"
|
|
:to="{ name: 'activities', query: { q: t, filter: 'all' } }"
|
|
>
|
|
#{{ t }}
|
|
</RouterLink>
|
|
</div>
|
|
</div>
|
|
<span class="type-ico" :style="typeColorStyle(typeView!.color)">
|
|
<UiIcon :name="typeView!.icon" :size="22" />
|
|
</span>
|
|
</header>
|
|
|
|
<dl>
|
|
<div>
|
|
<dt>
|
|
<UiIcon :name="typeView!.icon" :size="15" />
|
|
نوع فعالیت
|
|
</dt>
|
|
<dd>{{ typeView!.name }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>
|
|
<UiIcon name="calendar" :size="15" />
|
|
تاریخ و زمان
|
|
</dt>
|
|
<dd>{{ dueLabel }}</dd>
|
|
</div>
|
|
<div class="assignees-row">
|
|
<dt>
|
|
<UiIcon name="user" :size="15" />
|
|
مسئولان
|
|
</dt>
|
|
<dd class="person">
|
|
<UserMultiSelect
|
|
v-if="canReassign"
|
|
v-model="assignUserIds"
|
|
:users="users"
|
|
:disabled="saving"
|
|
:min="1"
|
|
@change="reassign"
|
|
/>
|
|
<template v-else>
|
|
{{ assigneeNames }}
|
|
</template>
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>
|
|
<UiIcon name="flag" :size="15" class="flag" :class="activity.priority || 'medium'" />
|
|
اولویت
|
|
</dt>
|
|
<dd class="priority" :class="activity.priority || 'medium'">
|
|
{{ priorityLabel[activity.priority || 'medium'] }}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>وضعیت</dt>
|
|
<dd>
|
|
<span class="status" :class="{ done: isDone }">{{ statusText }}</span>
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>
|
|
<UiIcon name="user" :size="15" />
|
|
ایجاد شده توسط
|
|
</dt>
|
|
<dd class="person">
|
|
<UserAvatar v-if="activity.createdBy" :name="activity.createdBy.fullName" :user-id="activity.createdBy.id" :size="28" />
|
|
{{ activity.createdBy?.fullName || '—' }}
|
|
</dd>
|
|
</div>
|
|
<div v-if="activity.lead">
|
|
<dt>
|
|
<UiIcon name="target" :size="15" />
|
|
لید
|
|
</dt>
|
|
<dd>
|
|
<RouterLink
|
|
class="lead-link"
|
|
:to="{ name: 'lead-detail', params: { id: activity.lead.id } }"
|
|
>
|
|
{{ activity.lead.title }}
|
|
</RouterLink>
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>
|
|
<UiIcon name="calendar" :size="15" />
|
|
تاریخ ایجاد
|
|
</dt>
|
|
<dd>{{ createdLabel }}</dd>
|
|
</div>
|
|
</dl>
|
|
</article>
|
|
|
|
<article v-if="typeDef?.fields.length" class="card type-card">
|
|
<h3>
|
|
<span class="type-ico sm" :style="typeColorStyle(typeView!.color)">
|
|
<UiIcon :name="typeView!.icon" :size="14" />
|
|
</span>
|
|
اطلاعات {{ typeView!.name }}
|
|
</h3>
|
|
<ActivityFieldsView
|
|
:fields="typeDef.fields"
|
|
:values="activity.fields ?? {}"
|
|
:participant-users="activity.participantUsers"
|
|
:busy="fieldsBusy"
|
|
editable
|
|
@toggle-item="toggleChecklistItem"
|
|
/>
|
|
</article>
|
|
|
|
<article class="card note">
|
|
<button class="note-head" type="button" @click="noteOpen = !noteOpen">
|
|
<strong>
|
|
<UiIcon name="document" :size="16" />
|
|
یادداشت
|
|
</strong>
|
|
<UiIcon name="chevron" :size="16" :class="{ open: noteOpen }" />
|
|
</button>
|
|
<div v-show="noteOpen">
|
|
<dl v-if="noteRows.length" class="note-rows">
|
|
<div v-for="(row, index) in noteRows" :key="`${row.label}-${index}`">
|
|
<dt>{{ row.label }}</dt>
|
|
<dd>
|
|
<template v-if="row.file && noteImageSrc(row.file)">
|
|
<a class="note-shot" :href="noteImageSrc(row.file)!" target="_blank" rel="noopener">
|
|
<img :src="noteImageSrc(row.file)!" :alt="row.file.name" />
|
|
</a>
|
|
<span>{{ row.file.name }}</span>
|
|
</template>
|
|
<span v-else-if="row.file">{{ row.file.name }}</span>
|
|
<template v-else>
|
|
<template v-for="(chip, chipIndex) in noteChips(row.text)" :key="chipIndex">
|
|
<a
|
|
v-if="chip.kind === 'link'"
|
|
:href="chip.href"
|
|
target="_blank"
|
|
rel="noopener"
|
|
>{{ chip.label }}</a>
|
|
<a
|
|
v-else-if="chip.kind === 'image'"
|
|
class="note-shot"
|
|
:href="chip.src"
|
|
target="_blank"
|
|
rel="noopener"
|
|
>
|
|
<img :src="chip.src" :alt="chip.name" />
|
|
</a>
|
|
<span v-else>{{ chip.text }}</span>
|
|
</template>
|
|
</template>
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
<p v-else-if="!noteExtra.length && !imageUrls.length" class="empty">یادداشتی ثبت نشده.</p>
|
|
<p v-if="noteExtra.length" class="note-extra">
|
|
<template v-for="(chip, index) in noteExtra" :key="index">
|
|
<a
|
|
v-if="chip.kind === 'link'"
|
|
:href="chip.href"
|
|
target="_blank"
|
|
rel="noopener"
|
|
>{{ chip.label }}</a>
|
|
<a
|
|
v-else-if="chip.kind === 'image'"
|
|
class="note-shot"
|
|
:href="chip.src"
|
|
target="_blank"
|
|
rel="noopener"
|
|
>
|
|
<img :src="chip.src" :alt="chip.name" />
|
|
</a>
|
|
<span v-else>{{ chip.text }}</span>
|
|
</template>
|
|
</p>
|
|
<div v-if="imageUrls.some((image) => image.kind === 'image')" class="note-photos">
|
|
<a
|
|
v-for="image in imageUrls.filter((item) => item.kind === 'image')"
|
|
:key="image.id"
|
|
:href="image.url"
|
|
target="_blank"
|
|
rel="noopener"
|
|
>
|
|
<img :src="image.url" :alt="image.name" />
|
|
</a>
|
|
</div>
|
|
<div v-if="imageUrls.some((image) => image.kind === 'audio')" class="note-voices">
|
|
<audio
|
|
v-for="image in imageUrls.filter((item) => item.kind === 'audio')"
|
|
:key="image.id"
|
|
:src="image.url"
|
|
controls
|
|
preload="metadata"
|
|
/>
|
|
</div>
|
|
<ul v-if="imageUrls.some((image) => image.kind === 'file')" class="note-files">
|
|
<li v-for="image in imageUrls.filter((item) => item.kind === 'file')" :key="image.id">
|
|
<a :href="image.url" :download="image.name">{{ image.name }}</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</article>
|
|
|
|
<p v-if="error" class="error">{{ error }}</p>
|
|
|
|
<ActivityCommentSheet
|
|
v-if="commentOpen"
|
|
:activity="activity"
|
|
:saving="saving"
|
|
:save="submitComment"
|
|
@close="commentOpen = false"
|
|
/>
|
|
<ActivityPostponeSheet
|
|
v-if="postponeOpen"
|
|
:due-at="activity.dueAt"
|
|
:saving="saving"
|
|
@close="postponeOpen = false"
|
|
@submit="postponeTo"
|
|
/>
|
|
</template>
|
|
|
|
<footer v-if="activity && !commentOpen && !postponeOpen" class="dock">
|
|
<button
|
|
v-if="isDone || activity.status === 'canceled'"
|
|
class="act primary"
|
|
type="button"
|
|
:disabled="saving"
|
|
@click="reopen"
|
|
>
|
|
<UiIcon name="clock" :size="16" />
|
|
بازگشایی
|
|
</button>
|
|
<button
|
|
v-else
|
|
class="act primary"
|
|
type="button"
|
|
:disabled="saving || isDone"
|
|
@click="complete"
|
|
>
|
|
<UiIcon name="check" :size="16" />
|
|
انجام شد
|
|
</button>
|
|
<button class="act ghost" type="button" :disabled="saving || isDone" @click="postponeOpen = true">
|
|
<UiIcon name="refresh" :size="16" />
|
|
تعویق
|
|
</button>
|
|
<button class="act ghost" type="button" :disabled="saving" @click="commentOpen = true">
|
|
<UiIcon name="chat" :size="16" />
|
|
ثبت نظر
|
|
</button>
|
|
</footer>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.page {
|
|
width: 100%;
|
|
max-width: 100%;
|
|
min-width: 0;
|
|
margin: 0;
|
|
display: grid;
|
|
gap: 0.9rem;
|
|
padding-bottom: 1rem;
|
|
overflow-x: clip;
|
|
}
|
|
|
|
.page > * {
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.nav {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
position: relative;
|
|
z-index: 4;
|
|
}
|
|
|
|
.brand {
|
|
font-weight: 600;
|
|
color: #0f766e;
|
|
letter-spacing: -0.04em;
|
|
}
|
|
|
|
.icon-btn {
|
|
width: 38px;
|
|
height: 38px;
|
|
border: 0;
|
|
background: #fff;
|
|
border-radius: var(--radius);
|
|
display: grid;
|
|
place-items: center;
|
|
color: #334155;
|
|
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.05);
|
|
}
|
|
|
|
.back :deep(.ico) {
|
|
transform: scaleX(-1);
|
|
}
|
|
|
|
.more {
|
|
position: relative;
|
|
}
|
|
|
|
.menu {
|
|
position: absolute;
|
|
top: calc(100% + 6px);
|
|
inset-inline-end: 0;
|
|
min-width: 10rem;
|
|
background: #fff;
|
|
border-radius: var(--radius);
|
|
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
|
padding: 0.35rem;
|
|
display: grid;
|
|
z-index: 6;
|
|
}
|
|
|
|
.menu a,
|
|
.menu button {
|
|
display: block;
|
|
width: 100%;
|
|
text-align: start;
|
|
border: 0;
|
|
background: none;
|
|
padding: 0.62rem 0.75rem;
|
|
border-radius: var(--radius);
|
|
font: inherit;
|
|
cursor: pointer;
|
|
color: #0f172a;
|
|
}
|
|
|
|
.menu .danger {
|
|
color: #b91c1c;
|
|
}
|
|
|
|
h1 {
|
|
margin: 0.1rem 0 0;
|
|
font-size: 1.35rem;
|
|
}
|
|
|
|
.neighbors {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.neighbors span {
|
|
color: #64748b;
|
|
font-size: 0.82rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.neighbor {
|
|
border: 0;
|
|
background: #fff;
|
|
color: #0f766e;
|
|
font: inherit;
|
|
font-weight: 700;
|
|
border-radius: var(--radius);
|
|
padding: 0.35rem 0.85rem;
|
|
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.05);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.neighbor:disabled {
|
|
color: #94a3b8;
|
|
cursor: default;
|
|
}
|
|
|
|
.muted,
|
|
.error {
|
|
margin: 0;
|
|
}
|
|
|
|
.muted {
|
|
color: #64748b;
|
|
}
|
|
|
|
.error {
|
|
color: #b91c1c;
|
|
}
|
|
|
|
.card {
|
|
background: #fff;
|
|
border-radius: var(--radius);
|
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
|
|
padding: 1rem 1.05rem;
|
|
}
|
|
|
|
.hero {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 0.8rem;
|
|
align-items: flex-start;
|
|
padding-bottom: 0.85rem;
|
|
}
|
|
|
|
.hero h2 {
|
|
margin: 0;
|
|
font-size: 1.05rem;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.35rem;
|
|
}
|
|
|
|
.star {
|
|
color: #cbd5e1;
|
|
margin-top: 0.15rem;
|
|
}
|
|
|
|
.sub {
|
|
margin: 0.4rem 0 0;
|
|
color: #64748b;
|
|
font-size: 0.84rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
}
|
|
|
|
.type-ico {
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: 999px;
|
|
display: grid;
|
|
place-items: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.tags {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.3rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.tag {
|
|
background: #f0fdfa;
|
|
color: #0f766e;
|
|
border-radius: 999px;
|
|
padding: 0.15rem 0.55rem;
|
|
font-size: 0.76rem;
|
|
font-weight: 600;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.type-ico.sm {
|
|
width: 28px;
|
|
height: 28px;
|
|
}
|
|
|
|
.type-card h3 {
|
|
margin: 0 0 0.4rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.45rem;
|
|
font-size: 0.95rem;
|
|
color: #0f172a;
|
|
}
|
|
|
|
dl {
|
|
margin: 0;
|
|
}
|
|
|
|
dl > div {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 0.8rem;
|
|
align-items: center;
|
|
padding: 0.78rem 0;
|
|
border-top: 1px solid #f1f5f9;
|
|
}
|
|
|
|
dt,
|
|
dd {
|
|
margin: 0;
|
|
}
|
|
|
|
dt {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
color: #64748b;
|
|
font-size: 0.86rem;
|
|
}
|
|
|
|
dd {
|
|
font-weight: 700;
|
|
text-align: start;
|
|
min-width: 0;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.lead-link {
|
|
color: var(--accent, #0f766e);
|
|
font-weight: 600;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.lead-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.person {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 0.45rem;
|
|
}
|
|
|
|
.assignees-row {
|
|
align-items: flex-start;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.assignees-row dt {
|
|
padding-top: 0.3rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.assignees-row dd {
|
|
flex: 1 1 16rem;
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.assignees-row dd :deep(.ant-select) {
|
|
width: 100%;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.assignees-row dd :deep(.ant-select-selection-overflow) {
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.flag.low,
|
|
.priority.low {
|
|
color: #16a34a;
|
|
}
|
|
|
|
.flag.medium,
|
|
.priority.medium {
|
|
color: #eab308;
|
|
}
|
|
|
|
.flag.high,
|
|
.priority.high {
|
|
color: #dc2626;
|
|
}
|
|
|
|
.status {
|
|
display: inline-flex;
|
|
border-radius: 999px;
|
|
padding: 0.22rem 0.65rem;
|
|
background: #ecfdf5;
|
|
color: #0f766e;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.status.done {
|
|
background: #dcfce7;
|
|
color: #166534;
|
|
}
|
|
|
|
.note-head {
|
|
width: 100%;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
border: 0;
|
|
background: none;
|
|
padding: 0;
|
|
font: inherit;
|
|
color: #0f766e;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.note-head :deep(.ico:last-child) {
|
|
transform: rotate(90deg);
|
|
color: #94a3b8;
|
|
}
|
|
|
|
.note-head :deep(.ico.open) {
|
|
transform: rotate(-90deg);
|
|
}
|
|
|
|
.note p {
|
|
margin: 0.75rem 0 0;
|
|
color: #334155;
|
|
line-height: 1.9;
|
|
font-size: 0.9rem;
|
|
white-space: pre-line;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.note p.empty {
|
|
color: #94a3b8;
|
|
}
|
|
|
|
.note-rows {
|
|
margin: 0.75rem 0 0;
|
|
display: grid;
|
|
gap: 0.55rem;
|
|
}
|
|
|
|
.note-rows > div {
|
|
display: grid;
|
|
grid-template-columns: minmax(7rem, 12rem) minmax(0, 1fr);
|
|
gap: 0.45rem 0.75rem;
|
|
align-items: start;
|
|
}
|
|
|
|
.note-rows dt {
|
|
color: #64748b;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.note-rows dd {
|
|
display: grid;
|
|
gap: 0.35rem;
|
|
justify-items: start;
|
|
}
|
|
|
|
.note-shot {
|
|
display: block;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.note-shot img {
|
|
display: block;
|
|
width: auto;
|
|
max-width: min(280px, 100%);
|
|
max-height: 220px;
|
|
object-fit: contain;
|
|
border-radius: 0.6rem;
|
|
border: 1px solid #e2e8f0;
|
|
background: #fff;
|
|
}
|
|
|
|
.note-photos {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
margin-top: 0.75rem;
|
|
}
|
|
|
|
.note-photos a {
|
|
width: 5.5rem;
|
|
height: 5.5rem;
|
|
}
|
|
|
|
.note-photos img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
border-radius: 0.6rem;
|
|
border: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.note-voices {
|
|
display: grid;
|
|
gap: 0.45rem;
|
|
margin-top: 0.75rem;
|
|
}
|
|
|
|
.note-voices audio {
|
|
width: 100%;
|
|
}
|
|
|
|
.note-files {
|
|
list-style: none;
|
|
margin: 0.75rem 0 0;
|
|
padding: 0;
|
|
display: grid;
|
|
gap: 0.35rem;
|
|
}
|
|
|
|
.note-files a {
|
|
color: #0f766e;
|
|
}
|
|
|
|
.dock {
|
|
position: static;
|
|
display: grid;
|
|
grid-template-columns: 1.15fr 1fr 1fr;
|
|
gap: 0.5rem;
|
|
width: 100%;
|
|
}
|
|
|
|
.act {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.3rem;
|
|
min-height: var(--control-h);
|
|
height: var(--control-h);
|
|
border-radius: var(--radius);
|
|
border: 1px solid #99f6e4;
|
|
background: #fff;
|
|
color: #0f766e;
|
|
font: inherit;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.act.primary {
|
|
background: #14b8a6;
|
|
border-color: #14b8a6;
|
|
color: #fff;
|
|
}
|
|
|
|
.act:disabled {
|
|
opacity: 0.45;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
@media (max-width: 899px) {
|
|
.page {
|
|
padding-bottom: calc(1rem + var(--mobile-tabbar-h));
|
|
}
|
|
}
|
|
</style>
|