Add editPolicy (default creator-only), enforce it in the API, surface clear permission messages, and drop the redundant edit menu now that detail is editable. Co-authored-by: Cursor <cursoragent@cursor.com>
704 lines
23 KiB
Vue
704 lines
23 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
import axios from 'axios';
|
|
import { CloseOutlined, PlusOutlined } from '@ant-design/icons-vue';
|
|
import {
|
|
Alert as AAlert,
|
|
Button as AButton,
|
|
Col as ACol,
|
|
Divider as ADivider,
|
|
Flex as AFlex,
|
|
Row as ARow,
|
|
Select as ASelect,
|
|
Image as AImage,
|
|
Spin as ASpin,
|
|
Typography,
|
|
theme,
|
|
} from 'ant-design-vue';
|
|
import type { ActivityDto } from '@mento/shared';
|
|
import { api } from '@/lib/api';
|
|
import { bindThreadMedia, parseActivityThread } from '@/lib/activity-thread';
|
|
import { noteChips } from '@/lib/lead-form-notes';
|
|
import {
|
|
formatClockFa,
|
|
formatJalaliDay,
|
|
formatJalaliDayClock,
|
|
formatJalaliNumeric,
|
|
formatLocalDateTimeInput,
|
|
parseLocalInput,
|
|
startOfLocalDay,
|
|
} from '@/lib/jalali';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import { canMutateActivity } from '@/lib/activity-access';
|
|
import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
|
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
|
|
|
const props = defineProps<{
|
|
activityId: string | null;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
close: [];
|
|
changed: [];
|
|
full: [id: string];
|
|
}>();
|
|
|
|
const { Text: ATypographyText, Paragraph: ATypographyParagraph, Link: ATypographyLink } = Typography;
|
|
const { token } = theme.useToken();
|
|
const auth = useAuthStore();
|
|
|
|
const rendered = ref(false);
|
|
const placed = ref(false);
|
|
const panelEl = ref<HTMLElement | null>(null);
|
|
const frame = ref({ left: 12, top: 12, width: 440, maxHeight: 560, shift: 88 });
|
|
let hideTimer = 0;
|
|
let reveal = false;
|
|
const VIEW_PAD = 16;
|
|
|
|
function cardEl(id: string) {
|
|
return document.querySelector<HTMLElement>(`.ant-card[data-card-id="${CSS.escape(id)}"]`);
|
|
}
|
|
|
|
function columnScroller(el: HTMLElement) {
|
|
let node = el.parentElement;
|
|
while (node) {
|
|
const style = getComputedStyle(node);
|
|
const scrolls = style.overflowY === 'auto' || style.overflowY === 'scroll';
|
|
if (scrolls && node.scrollHeight > node.clientHeight + 2) return node;
|
|
node = node.parentElement;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function panelExtent() {
|
|
const content = panelEl.value?.scrollHeight ?? 0;
|
|
return content > 80 ? content : 328;
|
|
}
|
|
|
|
/** Lift the column so a top-aligned panel fits. The last card may have no room left. */
|
|
function liftColumn(el: HTMLElement, cardTop: number, height: number, vh: number) {
|
|
const overflow = cardTop + height - (vh - VIEW_PAD);
|
|
if (overflow <= 8) return;
|
|
const scroller = columnScroller(el);
|
|
if (!scroller) return;
|
|
const room = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop;
|
|
const delta = Math.min(overflow, Math.max(0, room));
|
|
if (delta <= 8) return;
|
|
const motion = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
|
|
scroller.scrollBy({ top: delta, behavior: motion });
|
|
}
|
|
|
|
function measure() {
|
|
const id = props.activityId;
|
|
if (!id) return;
|
|
const el = cardEl(id);
|
|
if (!el) return;
|
|
let rect = el.getBoundingClientRect();
|
|
if (rect.width < 8) return;
|
|
const vw = window.innerWidth;
|
|
const vh = window.innerHeight;
|
|
const width = Math.min(440, vw - 24);
|
|
const gap = 10;
|
|
const side: 'left' | 'right' = rect.left >= vw - rect.right ? 'left' : 'right';
|
|
let left = side === 'left' ? rect.left - gap - width : rect.right + gap;
|
|
left = Math.max(12, Math.min(left, vw - width - 12));
|
|
const height = Math.min(panelExtent(), Math.max(160, vh - VIEW_PAD * 2));
|
|
if (reveal) {
|
|
reveal = false;
|
|
liftColumn(el, rect.top, height, vh);
|
|
rect = el.getBoundingClientRect();
|
|
}
|
|
let top = rect.top;
|
|
const maxTop = vh - VIEW_PAD - height;
|
|
if (top > maxTop) top = Math.max(VIEW_PAD, maxTop);
|
|
if (top < VIEW_PAD) top = VIEW_PAD;
|
|
const maxHeight = Math.max(160, vh - VIEW_PAD - top);
|
|
const travel = Math.min(120, Math.round(width * 0.28));
|
|
frame.value = { left, top, width, maxHeight, shift: side === 'left' ? travel : -travel };
|
|
}
|
|
|
|
const panelStyle = computed(() => ({
|
|
position: 'fixed' as const,
|
|
zIndex: 1050,
|
|
top: `${frame.value.top}px`,
|
|
left: `${frame.value.left}px`,
|
|
width: `${frame.value.width}px`,
|
|
maxHeight: `${frame.value.maxHeight}px`,
|
|
overflow: 'auto' as const,
|
|
boxSizing: 'border-box' as const,
|
|
padding: `${token.value.padding}px`,
|
|
background: token.value.colorBgElevated,
|
|
color: token.value.colorText,
|
|
border: `1px solid ${token.value.colorBorderSecondary}`,
|
|
borderRadius: `${token.value.borderRadiusLG}px`,
|
|
boxShadow: token.value.boxShadowSecondary,
|
|
transform: placed.value ? 'translate3d(0, 0, 0)' : `translate3d(${frame.value.shift}px, 0, 0)`,
|
|
opacity: placed.value ? 1 : 0,
|
|
transition: 'transform 220ms cubic-bezier(.2, .8, .2, 1), opacity 180ms ease',
|
|
pointerEvents: placed.value ? ('auto' as const) : ('none' as const),
|
|
}));
|
|
|
|
function onViewport() {
|
|
if (props.activityId) measure();
|
|
}
|
|
|
|
function onPointerDown(event: PointerEvent) {
|
|
if (!props.activityId || !placed.value) return;
|
|
const target = event.target;
|
|
if (!(target instanceof Node)) return;
|
|
if (panelEl.value?.contains(target)) return;
|
|
if (commentOpen.value) return;
|
|
if (target instanceof Element && target.closest('.ant-select-dropdown, #jalali-picker, .layer, .ant-image-preview-root, .ant-image-preview-wrap')) return;
|
|
const card = cardEl(props.activityId);
|
|
if (card?.contains(target)) return;
|
|
emit('close');
|
|
}
|
|
|
|
function onKey(event: KeyboardEvent) {
|
|
if (event.key !== 'Escape' || !props.activityId) return;
|
|
if (commentOpen.value) return;
|
|
emit('close');
|
|
}
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('resize', onViewport);
|
|
window.addEventListener('scroll', onViewport, true);
|
|
document.addEventListener('pointerdown', onPointerDown, true);
|
|
document.addEventListener('keydown', onKey);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener('resize', onViewport);
|
|
window.removeEventListener('scroll', onViewport, true);
|
|
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
document.removeEventListener('keydown', onKey);
|
|
window.clearTimeout(hideTimer);
|
|
clearNoteMedia();
|
|
});
|
|
|
|
const activity = ref<ActivityDto | null>(null);
|
|
const loading = ref(false);
|
|
const saving = ref(false);
|
|
const error = ref('');
|
|
const commentOpen = ref(false);
|
|
const postponeKey = ref('h1');
|
|
const customDue = ref('');
|
|
|
|
type NoteMedia = {
|
|
id: string;
|
|
name: string;
|
|
url: string;
|
|
kind: 'image' | 'audio' | 'file';
|
|
createdAt: string;
|
|
};
|
|
const noteMedia = ref<NoteMedia[]>([]);
|
|
|
|
function clearNoteMedia() {
|
|
for (const item of noteMedia.value) URL.revokeObjectURL(item.url);
|
|
noteMedia.value = [];
|
|
}
|
|
|
|
const isClosed = computed(
|
|
() => activity.value?.status === 'done' || activity.value?.status === 'canceled',
|
|
);
|
|
const canMutate = computed(() => canMutateActivity(activity.value, auth.user));
|
|
const needsEveryone = computed(
|
|
() => activity.value?.completionMode === 'all' && (activity.value.assignees?.length ?? 0) > 1,
|
|
);
|
|
const myShareDone = computed(() => {
|
|
const mine = auth.user?.id;
|
|
return Boolean(mine && activity.value?.completedByIds?.includes(mine));
|
|
});
|
|
const actionLabel = computed(() => {
|
|
if (isClosed.value) return 'بازگشایی';
|
|
if (needsEveryone.value && myShareDone.value) return 'لغو انجام';
|
|
return 'انجام شده';
|
|
});
|
|
|
|
const createdLabel = computed(() => {
|
|
const raw = activity.value?.createdAt;
|
|
if (!raw) return '—';
|
|
const date = new Date(raw);
|
|
return `${formatJalaliDay(date)} - ${formatClockFa(date)}`;
|
|
});
|
|
|
|
const thread = computed(() => parseActivityThread(activity.value?.description));
|
|
const plainNote = computed(() => (thread.value ? '' : activity.value?.description?.trim() || ''));
|
|
const threadBlocks = computed(() => {
|
|
const entries = thread.value;
|
|
if (!entries) return null;
|
|
return bindThreadMedia(entries, noteMedia.value);
|
|
});
|
|
const looseImages = computed(() =>
|
|
(threadBlocks.value?.loose ?? noteMedia.value).filter((item) => item.kind === 'image'),
|
|
);
|
|
|
|
const noteScrollStyle = computed(() => ({
|
|
maxHeight: '220px',
|
|
overflow: 'auto',
|
|
}));
|
|
|
|
const noteBubbleStyle = computed(() => ({
|
|
padding: '8px 12px',
|
|
border: `1px solid ${token.value.colorBorderSecondary}`,
|
|
borderRadius: `${token.value.borderRadius}px`,
|
|
background: token.value.colorFillAlter,
|
|
}));
|
|
|
|
const postponeBase = computed(() => {
|
|
const now = new Date();
|
|
const due = activity.value?.dueAt ? new Date(activity.value.dueAt) : now;
|
|
if (Number.isNaN(due.getTime()) || due.getTime() <= now.getTime()) return now;
|
|
return due;
|
|
});
|
|
|
|
function addMinutes(mins: number) {
|
|
return new Date(postponeBase.value.getTime() + mins * 60_000);
|
|
}
|
|
|
|
function tomorrowMorning() {
|
|
const d = startOfLocalDay(new Date());
|
|
d.setDate(d.getDate() + 1);
|
|
d.setHours(10, 0, 0, 0);
|
|
return d;
|
|
}
|
|
|
|
function nextSaturdayMorning() {
|
|
const d = startOfLocalDay(new Date());
|
|
const untilSaturday = (6 - d.getDay() + 7) % 7;
|
|
d.setDate(d.getDate() + (untilSaturday === 0 ? 7 : untilSaturday));
|
|
d.setHours(10, 0, 0, 0);
|
|
return d;
|
|
}
|
|
|
|
const postponeChoices = computed(() => {
|
|
const h1 = addMinutes(60);
|
|
const morning = tomorrowMorning();
|
|
const saturday = nextSaturdayMorning();
|
|
const week = new Date(postponeBase.value.getTime());
|
|
week.setDate(week.getDate() + 7);
|
|
return [
|
|
{ key: 'h1', label: `یک ساعت دیرتر · ${formatClockFa(h1)}`, at: h1 },
|
|
{ key: 'tomorrow-am', label: `فردا صبح · ${formatClockFa(morning)}`, at: morning },
|
|
{ key: 'next-saturday', label: `شنبه آینده · ${formatJalaliDayClock(saturday)}`, at: saturday },
|
|
{ key: 'next-week', label: `هفته آینده · ${formatJalaliDayClock(week)}`, at: week },
|
|
{ key: 'custom', label: 'زمان دلخواه', at: null },
|
|
];
|
|
});
|
|
|
|
const postponeReady = computed(() => {
|
|
if (postponeKey.value === 'custom') return Boolean(parseLocalInput(customDue.value));
|
|
return postponeChoices.value.some((item) => item.key === postponeKey.value && item.at);
|
|
});
|
|
|
|
function resetDrafts() {
|
|
commentOpen.value = false;
|
|
postponeKey.value = 'h1';
|
|
customDue.value = '';
|
|
}
|
|
|
|
function apiMessage(e: unknown, fallback: string) {
|
|
if (axios.isAxiosError(e)) {
|
|
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
|
|
if (Array.isArray(msg)) return msg[0] ?? fallback;
|
|
if (typeof msg === 'string' && msg) return msg;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
async function loadNoteMedia(row: ActivityDto) {
|
|
clearNoteMedia();
|
|
const next: NoteMedia[] = [];
|
|
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',
|
|
});
|
|
const url = URL.createObjectURL(data);
|
|
if (props.activityId !== row.id) {
|
|
URL.revokeObjectURL(url);
|
|
continue;
|
|
}
|
|
next.push({
|
|
id: image.id,
|
|
name: image.fileName,
|
|
url,
|
|
kind,
|
|
createdAt: image.createdAt,
|
|
});
|
|
} catch {
|
|
/* فایل در دسترس نیست */
|
|
}
|
|
}
|
|
if (props.activityId !== row.id) {
|
|
for (const item of next) URL.revokeObjectURL(item.url);
|
|
return;
|
|
}
|
|
noteMedia.value = next;
|
|
await nextTick();
|
|
measure();
|
|
}
|
|
|
|
async function load(id: string) {
|
|
loading.value = true;
|
|
error.value = '';
|
|
try {
|
|
const { data } = await api.get<ActivityDto>(`/activities/${id}`);
|
|
if (props.activityId !== id) return;
|
|
activity.value = data;
|
|
if (!customDue.value) customDue.value = formatLocalDateTimeInput(addMinutes(60));
|
|
void loadNoteMedia(data);
|
|
} catch (e) {
|
|
if (props.activityId === id) {
|
|
activity.value = null;
|
|
error.value = apiMessage(e, 'بارگذاری فعالیت ناموفق بود.');
|
|
}
|
|
} finally {
|
|
if (props.activityId === id) {
|
|
loading.value = false;
|
|
reveal = true;
|
|
await nextTick();
|
|
measure();
|
|
}
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => props.activityId,
|
|
async (id, prev) => {
|
|
if (!id) {
|
|
activity.value = null;
|
|
error.value = '';
|
|
resetDrafts();
|
|
clearNoteMedia();
|
|
placed.value = false;
|
|
window.clearTimeout(hideTimer);
|
|
hideTimer = window.setTimeout(() => {
|
|
if (!props.activityId) rendered.value = false;
|
|
}, 230);
|
|
return;
|
|
}
|
|
window.clearTimeout(hideTimer);
|
|
if (prev && prev !== id) {
|
|
resetDrafts();
|
|
activity.value = null;
|
|
clearNoteMedia();
|
|
}
|
|
if (prev && prev !== id && rendered.value) placed.value = false;
|
|
rendered.value = true;
|
|
measure();
|
|
await nextTick();
|
|
measure();
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
if (props.activityId === id) placed.value = true;
|
|
});
|
|
});
|
|
void load(id);
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
async function runAction() {
|
|
const id = props.activityId;
|
|
const row = activity.value;
|
|
if (!id || !row || saving.value) return;
|
|
saving.value = true;
|
|
error.value = '';
|
|
try {
|
|
if (isClosed.value) {
|
|
if (!canMutate.value) {
|
|
error.value = 'شما مجوز ویرایش این فعالیت را ندارید.';
|
|
return;
|
|
}
|
|
await api.patch(`/activities/${id}`, { status: 'open' });
|
|
await load(id);
|
|
emit('changed');
|
|
return;
|
|
}
|
|
const undo = needsEveryone.value && myShareDone.value;
|
|
const { data } = await api.patch<ActivityDto>(`/activities/${id}/complete`, undefined, {
|
|
params: undo ? { undo: '1' } : undefined,
|
|
});
|
|
if (data.status === 'done') {
|
|
emit('changed');
|
|
emit('close');
|
|
return;
|
|
}
|
|
activity.value = data;
|
|
emit('changed');
|
|
} catch (e) {
|
|
error.value = apiMessage(e, 'ثبت تغییر ناموفق بود.');
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function postponeTo(iso: string) {
|
|
const id = props.activityId;
|
|
if (!id || saving.value) return;
|
|
saving.value = true;
|
|
error.value = '';
|
|
try {
|
|
await api.patch(`/activities/${id}`, { dueAt: iso });
|
|
await load(id);
|
|
emit('changed');
|
|
} catch (e) {
|
|
error.value = apiMessage(e, 'تعویق فعالیت ناموفق بود.');
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function submitComment(payload: { text: string; photoOnly: boolean }) {
|
|
const id = props.activityId;
|
|
const row = activity.value;
|
|
const body = payload.text.trim() || (payload.photoOnly ? 'عکس پیوست شد.' : '');
|
|
if (!id || !row || !body) return;
|
|
saving.value = true;
|
|
error.value = '';
|
|
try {
|
|
const prev = row.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}`;
|
|
await api.patch(`/activities/${id}`, { description: prev ? `${prev}\n\n${entry}` : entry });
|
|
commentOpen.value = false;
|
|
await load(id);
|
|
emit('changed');
|
|
} catch (e) {
|
|
error.value = apiMessage(e, 'ثبت نظر ناموفق بود.');
|
|
throw e;
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function applyPostpone() {
|
|
if (postponeKey.value === 'custom') {
|
|
const at = parseLocalInput(customDue.value);
|
|
if (!at) return;
|
|
await postponeTo(at.toISOString());
|
|
return;
|
|
}
|
|
const at = postponeChoices.value.find((item) => item.key === postponeKey.value)?.at;
|
|
if (at) await postponeTo(at.toISOString());
|
|
}
|
|
|
|
function openFull() {
|
|
if (!props.activityId) return;
|
|
emit('full', props.activityId);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="rendered"
|
|
ref="panelEl"
|
|
role="dialog"
|
|
dir="rtl"
|
|
:aria-label="activity?.title || 'جزئیات فعالیت'"
|
|
:style="panelStyle"
|
|
>
|
|
<a-flex justify="flex-end" :style="{ marginBottom: '8px' }">
|
|
<a-button type="text" aria-label="بستن" @click="emit('close')">
|
|
<template #icon><CloseOutlined /></template>
|
|
</a-button>
|
|
</a-flex>
|
|
|
|
<a-spin :spinning="loading">
|
|
<a-alert v-if="error" type="error" :message="error" show-icon :style="{ marginBottom: '12px' }" />
|
|
<template v-if="activity">
|
|
<a-row :gutter="[16, 12]">
|
|
<a-col :xs="24" :sm="12">
|
|
<a-flex vertical :gap="2">
|
|
<a-typography-text type="secondary">ایجاد شده توسط</a-typography-text>
|
|
<a-typography-text>{{ activity.createdBy?.fullName || '—' }}</a-typography-text>
|
|
</a-flex>
|
|
</a-col>
|
|
<a-col :xs="24" :sm="12">
|
|
<a-flex vertical :gap="2">
|
|
<a-typography-text type="secondary">تاریخ ایجاد</a-typography-text>
|
|
<a-typography-text>{{ createdLabel }}</a-typography-text>
|
|
</a-flex>
|
|
</a-col>
|
|
</a-row>
|
|
|
|
<a-divider :style="{ margin: '16px 0 12px' }" />
|
|
<a-flex justify="space-between" align="center" :style="{ marginBottom: '8px' }">
|
|
<a-typography-text strong>یادداشت</a-typography-text>
|
|
<a-button
|
|
type="primary"
|
|
shape="circle"
|
|
aria-label="یادداشت جدید"
|
|
:disabled="!canMutate"
|
|
@click="commentOpen = true"
|
|
>
|
|
<template #icon><PlusOutlined /></template>
|
|
</a-button>
|
|
</a-flex>
|
|
<div class="note-photos" :style="noteScrollStyle">
|
|
<a-flex v-if="threadBlocks" vertical :gap="8">
|
|
<div
|
|
v-for="(entry, index) in threadBlocks.blocks"
|
|
:key="`${entry.dateLabel}-${entry.timeLabel}-${index}`"
|
|
:style="noteBubbleStyle"
|
|
>
|
|
<a-flex v-if="entry.dateLabel" justify="space-between" align="center" :gap="8">
|
|
<a-typography-text type="secondary">
|
|
{{ entry.dateLabel }} ساعت {{ entry.timeLabel }}
|
|
</a-typography-text>
|
|
<a-typography-text v-if="entry.author" strong :style="{ color: token.colorPrimary }">
|
|
{{ entry.author }}
|
|
</a-typography-text>
|
|
</a-flex>
|
|
<a-typography-paragraph v-if="entry.body" :style="{ margin: entry.dateLabel ? '4px 0 0' : 0, whiteSpace: 'pre-wrap' }">
|
|
<template v-for="(chip, chipIndex) in noteChips(entry.body)" :key="chipIndex">
|
|
<a-image
|
|
v-if="chip.kind === 'image'"
|
|
:src="chip.src"
|
|
:alt="chip.name"
|
|
:width="72"
|
|
:height="72"
|
|
:preview="{ zIndex: 1300 }"
|
|
:style="{ display: 'block', marginTop: '8px' }"
|
|
/>
|
|
<a-typography-link
|
|
v-else-if="chip.kind === 'link'"
|
|
:href="chip.href"
|
|
target="_blank"
|
|
rel="noopener"
|
|
>
|
|
{{ chip.label }}
|
|
</a-typography-link>
|
|
<span v-else>{{ chip.text }}</span>
|
|
</template>
|
|
</a-typography-paragraph>
|
|
<a-flex
|
|
v-if="entry.media.some((item) => item.kind === 'image')"
|
|
wrap="wrap"
|
|
:gap="8"
|
|
:style="{ marginTop: '8px' }"
|
|
>
|
|
<a-image
|
|
v-for="image in entry.media.filter((item) => item.kind === 'image')"
|
|
:key="image.id"
|
|
:src="image.url"
|
|
:alt="image.name"
|
|
:width="72"
|
|
:height="72"
|
|
:preview="{ zIndex: 1300 }"
|
|
/>
|
|
</a-flex>
|
|
</div>
|
|
<div v-for="image in threadBlocks.loose.filter((item) => item.kind === 'image')" :key="image.id" :style="noteBubbleStyle">
|
|
<a-image :src="image.url" :alt="image.name" :width="72"
|
|
:height="72" :preview="{ zIndex: 1300 }" />
|
|
</div>
|
|
</a-flex>
|
|
<div v-else-if="plainNote || looseImages.length" :style="noteBubbleStyle">
|
|
<a-typography-paragraph v-if="plainNote" :style="{ margin: 0, whiteSpace: 'pre-wrap' }">
|
|
<template v-for="(chip, chipIndex) in noteChips(plainNote)" :key="chipIndex">
|
|
<a-image
|
|
v-if="chip.kind === 'image'"
|
|
:src="chip.src"
|
|
:alt="chip.name"
|
|
:width="72"
|
|
:height="72"
|
|
:preview="{ zIndex: 1300 }"
|
|
:style="{ display: 'block', marginTop: '8px' }"
|
|
/>
|
|
<a-typography-link
|
|
v-else-if="chip.kind === 'link'"
|
|
:href="chip.href"
|
|
target="_blank"
|
|
rel="noopener"
|
|
>
|
|
{{ chip.label }}
|
|
</a-typography-link>
|
|
<span v-else>{{ chip.text }}</span>
|
|
</template>
|
|
</a-typography-paragraph>
|
|
<a-flex v-if="looseImages.length" wrap="wrap" :gap="8" :style="{ marginTop: plainNote ? '8px' : 0 }">
|
|
<a-image
|
|
v-for="image in looseImages"
|
|
:key="image.id"
|
|
:src="image.url"
|
|
:alt="image.name"
|
|
:width="72"
|
|
:height="72"
|
|
:preview="{ zIndex: 1300 }"
|
|
/>
|
|
</a-flex>
|
|
</div>
|
|
<a-typography-text v-else type="secondary">یادداشتی ثبت نشده.</a-typography-text>
|
|
</div>
|
|
|
|
<a-flex vertical :gap="8" :style="{ marginTop: '12px' }">
|
|
<a-flex align="center" :gap="8">
|
|
<a-select
|
|
v-model:value="postponeKey"
|
|
:disabled="saving || loading || isClosed || !canMutate"
|
|
:options="postponeChoices.map((item) => ({ value: item.key, label: item.label }))"
|
|
:dropdown-style="{ zIndex: 1200 }"
|
|
style="flex: 1"
|
|
/>
|
|
<a-button
|
|
type="primary"
|
|
:disabled="saving || loading || isClosed || !canMutate || !postponeReady"
|
|
@click="applyPostpone"
|
|
>
|
|
تعویق
|
|
</a-button>
|
|
</a-flex>
|
|
<JalaliDateInput
|
|
v-if="postponeKey === 'custom'"
|
|
v-model="customDue"
|
|
mode="datetime"
|
|
placeholder="تاریخ و ساعت"
|
|
:disabled="!canMutate"
|
|
/>
|
|
</a-flex>
|
|
|
|
<a-divider :style="{ margin: '16px 0 12px' }" />
|
|
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
|
<a-button
|
|
type="primary"
|
|
:disabled="saving || loading || (isClosed && !canMutate)"
|
|
@click="runAction"
|
|
>
|
|
{{ actionLabel }}
|
|
</a-button>
|
|
<a-button type="link" @click="openFull">مشاهدهٔ کامل</a-button>
|
|
</a-flex>
|
|
</template>
|
|
</a-spin>
|
|
|
|
<ActivityCommentSheet
|
|
v-if="commentOpen && activity"
|
|
:activity="activity"
|
|
:saving="saving"
|
|
:overlay-z-index="1200"
|
|
:save="submitComment"
|
|
@close="commentOpen = false"
|
|
/>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.note-photos :deep(.ant-image) {
|
|
width: 72px;
|
|
height: 72px;
|
|
}
|
|
.note-photos :deep(.ant-image-img) {
|
|
width: 72px;
|
|
height: 72px;
|
|
object-fit: cover;
|
|
}
|
|
</style>
|