feat(activities): peek at a card from the board
Let people read notes, postpone, and mark an activity done beside the card instead of opening the full page. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -360,7 +360,7 @@ function place() {
|
||||
|
||||
function liftAboveOverlays() {
|
||||
let z = 120;
|
||||
for (const el of document.querySelectorAll('.ant-modal-wrap, .ant-drawer, .ant-popover, [data-lead-filter]')) {
|
||||
for (const el of document.querySelectorAll('.ant-modal-wrap, .ant-drawer, .ant-popover, [role="dialog"], [data-lead-filter]')) {
|
||||
const zi = Number.parseInt(window.getComputedStyle(el).zIndex, 10);
|
||||
if (Number.isFinite(zi)) z = Math.max(z, zi + 20);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { activityTypeView } from '@/lib/activity-types';
|
||||
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
|
||||
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
|
||||
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
|
||||
import ActivityQuickView from '@/components/activities/ActivityQuickView.vue';
|
||||
import {
|
||||
ACTIVITY_BOARD_COPY,
|
||||
initialsFromTitle,
|
||||
@@ -64,6 +65,9 @@ const draggingColumnId = ref<string | null>(null);
|
||||
const overlayDeal = ref<PipelineDeal | null>(null);
|
||||
const overlayAccent = ref<PipelineAccent>('green');
|
||||
const overlayPos = ref({ x: 0, y: 0 });
|
||||
const quickId = ref<string | null>(null);
|
||||
const peekId = ref<string | null>(null);
|
||||
let peekLinger = 0;
|
||||
const moving = ref(false);
|
||||
const stageBusy = ref(false);
|
||||
const localOrder = ref<string[] | null>(null);
|
||||
@@ -223,6 +227,7 @@ function onMenuActivity(id: string) {
|
||||
}
|
||||
|
||||
function onDragStart(deal: PipelineDeal, event: DragEvent) {
|
||||
if (quickId.value) closeQuick();
|
||||
draggingId.value = deal.id;
|
||||
overlayDeal.value = deal;
|
||||
const sid = findStageOf(deal.id);
|
||||
@@ -420,7 +425,7 @@ async function onAddStage(name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function openActivity(id: string) {
|
||||
function activityQuery(id: string) {
|
||||
const query: Record<string, string> = {
|
||||
from: 'desk',
|
||||
nav: crypto.randomUUID(),
|
||||
@@ -440,7 +445,27 @@ function openActivity(id: string) {
|
||||
if (source.due) query.due = source.due;
|
||||
const columnId = findStageOf(id);
|
||||
if (columnId) query.stage = columnId;
|
||||
void router.push({ name: 'activity-detail', params: { id }, query });
|
||||
return query;
|
||||
}
|
||||
|
||||
function openActivity(id: string) {
|
||||
window.clearTimeout(peekLinger);
|
||||
quickId.value = id;
|
||||
peekId.value = id;
|
||||
}
|
||||
|
||||
function closeQuick() {
|
||||
quickId.value = null;
|
||||
const id = peekId.value;
|
||||
window.clearTimeout(peekLinger);
|
||||
peekLinger = window.setTimeout(() => {
|
||||
if (!quickId.value && peekId.value === id) peekId.value = null;
|
||||
}, 650);
|
||||
}
|
||||
|
||||
function openFullActivity(id: string) {
|
||||
quickId.value = null;
|
||||
void router.push({ name: 'activity-detail', params: { id }, query: activityQuery(id) });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -456,6 +481,7 @@ function openActivity(id: string) {
|
||||
:copy="ACTIVITY_BOARD_COPY"
|
||||
:select-mode="selectMode"
|
||||
:selected-ids="selectedIds"
|
||||
:peek-id="peekId"
|
||||
@drag-start="onDragStart"
|
||||
@drag-end="onDragEnd"
|
||||
@drag-over="onDragOver"
|
||||
@@ -473,6 +499,12 @@ function openActivity(id: string) {
|
||||
@add-stage="onAddStage"
|
||||
@load-more="loadMore"
|
||||
/>
|
||||
<ActivityQuickView
|
||||
:activity-id="quickId"
|
||||
@close="closeQuick"
|
||||
@changed="loadAll()"
|
||||
@full="openFullActivity"
|
||||
/>
|
||||
<Teleport to="body">
|
||||
<DealCardOverlay
|
||||
v-if="overlayDeal && draggingId"
|
||||
|
||||
@@ -17,6 +17,8 @@ const props = defineProps<{
|
||||
activity: ActivityDto;
|
||||
saving?: boolean;
|
||||
save: (payload: { text: string; photoOnly: boolean }) => Promise<void>;
|
||||
/** بالاتر از مودال خلاصهٔ فعالیت */
|
||||
overlayZIndex?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -355,7 +357,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="layer" role="presentation">
|
||||
<div class="layer" role="presentation" :style="overlayZIndex ? { zIndex: overlayZIndex } : undefined">
|
||||
<div class="backdrop" aria-hidden="true" />
|
||||
<section
|
||||
class="sheet"
|
||||
|
||||
@@ -13,6 +13,8 @@ type PostponeKey = 'h1' | 'tomorrow-am' | 'next-saturday' | 'next-week' | 'custo
|
||||
const props = defineProps<{
|
||||
dueAt?: string | null;
|
||||
saving?: boolean;
|
||||
/** بالاتر از مودال خلاصهٔ فعالیت */
|
||||
overlayZIndex?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -154,7 +156,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="layer" role="presentation">
|
||||
<div class="layer" role="presentation" :style="overlayZIndex ? { zIndex: overlayZIndex } : undefined">
|
||||
<button class="backdrop" type="button" aria-label="بستن" @click="close" />
|
||||
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="postpone-title">
|
||||
<div class="handle" aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
<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>
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CalendarOutlined,
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
FlagFilled,
|
||||
MoreOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
@@ -31,6 +32,7 @@ const props = withDefaults(
|
||||
copy?: PipelineUiCopy;
|
||||
selectMode?: boolean;
|
||||
selected?: boolean;
|
||||
peeking?: boolean;
|
||||
}>(),
|
||||
{ copy: () => DEAL_BOARD_COPY },
|
||||
);
|
||||
@@ -80,9 +82,15 @@ const cardStyle = computed(() => ({
|
||||
transform: props.dragging ? 'scale(0.98)' : 'none',
|
||||
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
|
||||
margin: 0,
|
||||
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
|
||||
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
|
||||
boxShadow: props.dragging ? token.value.boxShadow : token.value.boxShadowTertiary,
|
||||
position: props.peeking ? 'relative' : undefined,
|
||||
zIndex: props.peeking ? 2 : undefined,
|
||||
background: props.selected || props.peeking ? token.value.colorPrimaryBg : token.value.colorBgContainer,
|
||||
borderColor: props.selected || props.peeking ? token.value.colorPrimary : token.value.colorBorderSecondary,
|
||||
boxShadow: props.peeking
|
||||
? `0 0 0 2px ${token.value.colorPrimary}`
|
||||
: props.dragging
|
||||
? token.value.boxShadow
|
||||
: token.value.boxShadowTertiary,
|
||||
}));
|
||||
</script>
|
||||
|
||||
@@ -124,21 +132,28 @@ const cardStyle = computed(() => ({
|
||||
</template>
|
||||
</a-button>
|
||||
<div :style="{ flex: 1, minWidth: 0 }">
|
||||
<a-typography-text
|
||||
ellipsis
|
||||
:style="{
|
||||
display: 'block',
|
||||
margin: 0,
|
||||
lineHeight: 1.35,
|
||||
cursor: 'pointer',
|
||||
fontSize: `${token.fontSizeSM}px`,
|
||||
fontWeight: 600,
|
||||
}"
|
||||
:title="deal.title"
|
||||
@click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)"
|
||||
>
|
||||
{{ deal.title }}
|
||||
</a-typography-text>
|
||||
<a-flex align="center" :gap="6" :style="{ minWidth: 0 }">
|
||||
<a-tooltip v-if="copy.quickView && priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
|
||||
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '14px', flexShrink: 0 }" />
|
||||
</a-tooltip>
|
||||
<a-typography-text
|
||||
ellipsis
|
||||
:style="{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'block',
|
||||
margin: 0,
|
||||
lineHeight: 1.35,
|
||||
cursor: 'pointer',
|
||||
fontSize: `${token.fontSizeSM}px`,
|
||||
fontWeight: 600,
|
||||
}"
|
||||
:title="deal.title"
|
||||
@click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)"
|
||||
>
|
||||
{{ deal.title }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-typography-text
|
||||
v-if="deal.description"
|
||||
type="secondary"
|
||||
@@ -209,7 +224,15 @@ const cardStyle = computed(() => ({
|
||||
{{ money.unit }}
|
||||
</a-typography-text>
|
||||
</a-typography-text>
|
||||
<a-tooltip v-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
|
||||
<a-button
|
||||
v-if="copy.quickView && !selectMode"
|
||||
type="text"
|
||||
aria-label="جزئیات فعالیت"
|
||||
@click.stop="emit('open', deal.id)"
|
||||
>
|
||||
<template #icon><EyeOutlined /></template>
|
||||
</a-button>
|
||||
<a-tooltip v-else-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
|
||||
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
|
||||
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
|
||||
</span>
|
||||
|
||||
@@ -21,8 +21,9 @@ withDefaults(
|
||||
copy?: PipelineUiCopy;
|
||||
selectMode?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
peekId?: string | null;
|
||||
}>(),
|
||||
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set() },
|
||||
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set(), peekId: null },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -109,6 +110,7 @@ function submitAdd() {
|
||||
:copy="copy"
|
||||
:select-mode="selectMode"
|
||||
:selected-ids="selectedIds"
|
||||
:peek-id="peekId"
|
||||
:is-first="index === 0"
|
||||
:is-last="index === stages.length - 1"
|
||||
@drag-start="(d, e) => emit('dragStart', d, e)"
|
||||
|
||||
@@ -26,8 +26,9 @@ const props = withDefaults(
|
||||
copy?: PipelineUiCopy;
|
||||
selectMode?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
peekId?: string | null;
|
||||
}>(),
|
||||
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set() },
|
||||
{ copy: () => DEAL_BOARD_COPY, selectedIds: () => new Set(), peekId: null },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -314,6 +315,7 @@ function onMenuClick({ key }: { key: string | number }) {
|
||||
:copy="copy"
|
||||
:select-mode="selectMode"
|
||||
:selected="selectedIds.has(deal.id)"
|
||||
:peeking="peekId === deal.id"
|
||||
@drag-start="(d, e) => emit('dragStart', d, e)"
|
||||
@drag-end="clearDragUi(); emit('dragEnd')"
|
||||
@open="emit('openDeal', $event)"
|
||||
|
||||
@@ -34,6 +34,8 @@ export type PipelineUiCopy = {
|
||||
itemMenuAria: string;
|
||||
listColTitle: string;
|
||||
hideAmount?: boolean;
|
||||
/** کارت فعالیت: پرچم کنار عنوان و پنجرهٔ خلاصه بهجای ورود مستقیم به جزئیات */
|
||||
quickView?: boolean;
|
||||
};
|
||||
|
||||
export const DEAL_BOARD_COPY: PipelineUiCopy = {
|
||||
@@ -63,6 +65,7 @@ export const ACTIVITY_BOARD_COPY: PipelineUiCopy = {
|
||||
itemMenuAria: 'گزینههای فعالیت',
|
||||
listColTitle: 'فعالیت',
|
||||
hideAmount: true,
|
||||
quickView: true,
|
||||
};
|
||||
|
||||
export type PipelineStage = {
|
||||
|
||||
Reference in New Issue
Block a user