539 lines
18 KiB
Vue
539 lines
18 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,
|
||
|
|
Spin as ASpin,
|
||
|
|
Typography,
|
||
|
|
theme,
|
||
|
|
} from 'ant-design-vue';
|
||
|
|
import type { ActivityDto } from '@mento/shared';
|
||
|
|
import { api } from '@/lib/api';
|
||
|
|
import { parseActivityThread } from '@/lib/activity-thread';
|
||
|
|
import {
|
||
|
|
formatClockFa,
|
||
|
|
formatJalaliDay,
|
||
|
|
formatJalaliDayClock,
|
||
|
|
formatJalaliNumeric,
|
||
|
|
formatLocalDateTimeInput,
|
||
|
|
parseLocalInput,
|
||
|
|
startOfLocalDay,
|
||
|
|
} from '@/lib/jalali';
|
||
|
|
import { useAuthStore } from '@/stores/auth';
|
||
|
|
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 } = 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')) 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);
|
||
|
|
});
|
||
|
|
|
||
|
|
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('');
|
||
|
|
|
||
|
|
const isClosed = computed(
|
||
|
|
() => activity.value?.status === 'done' || activity.value?.status === 'canceled',
|
||
|
|
);
|
||
|
|
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 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 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));
|
||
|
|
} 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();
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
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) {
|
||
|
|
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="یادداشت جدید" @click="commentOpen = true">
|
||
|
|
<template #icon><PlusOutlined /></template>
|
||
|
|
</a-button>
|
||
|
|
</a-flex>
|
||
|
|
<div :style="noteScrollStyle">
|
||
|
|
<a-flex v-if="thread?.length" vertical :gap="8">
|
||
|
|
<div
|
||
|
|
v-for="(entry, index) in thread"
|
||
|
|
: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' }">
|
||
|
|
{{ entry.body }}
|
||
|
|
</a-typography-paragraph>
|
||
|
|
</div>
|
||
|
|
</a-flex>
|
||
|
|
<div v-else-if="plainNote" :style="noteBubbleStyle">
|
||
|
|
<a-typography-paragraph :style="{ margin: 0, whiteSpace: 'pre-wrap' }">
|
||
|
|
{{ plainNote }}
|
||
|
|
</a-typography-paragraph>
|
||
|
|
</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"
|
||
|
|
: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 || !postponeReady" @click="applyPostpone">
|
||
|
|
تعویق
|
||
|
|
</a-button>
|
||
|
|
</a-flex>
|
||
|
|
<JalaliDateInput
|
||
|
|
v-if="postponeKey === 'custom'"
|
||
|
|
v-model="customDue"
|
||
|
|
mode="datetime"
|
||
|
|
placeholder="تاریخ و ساعت"
|
||
|
|
/>
|
||
|
|
</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" @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>
|