Files
CRM_Montzar/apps/web/src/components/activities/ActivityPostponeSheet.vue
T
MahdiandCursor fdf1fbe4aa 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>
2026-11-10 20:10:16 +03:30

381 lines
8.4 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import {
formatClockFa,
formatJalaliDayClock,
startOfLocalDay,
} from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
type PostponeKey = 'h1' | 'tomorrow-am' | 'next-saturday' | 'next-week' | 'custom';
const props = defineProps<{
dueAt?: string | null;
saving?: boolean;
/** بالاتر از مودال خلاصهٔ فعالیت */
overlayZIndex?: number;
}>();
const emit = defineEmits<{
close: [];
submit: [iso: string];
}>();
const nowTick = ref(Date.now());
const customOpen = ref(false);
const customLocal = ref('');
let tickTimer: number | null = null;
const base = computed(() => {
const now = new Date(nowTick.value);
const due = props.dueAt ? new Date(props.dueAt) : now;
if (Number.isNaN(due.getTime()) || due.getTime() <= now.getTime()) return now;
return due;
});
function addMinutes(mins: number) {
return new Date(base.value.getTime() + mins * 60_000);
}
function tomorrowAt(hour: number, minute = 0) {
const d = startOfLocalDay(new Date(nowTick.value));
d.setDate(d.getDate() + 1);
d.setHours(hour, minute, 0, 0);
return d;
}
/** شنبهٔ بعد، ساعت ۱۰. اگر امروز شنبه باشد، شنبهٔ هفتهٔ بعد. */
function nextSaturdayMorning() {
const d = startOfLocalDay(new Date(nowTick.value));
const untilSaturday = (6 - d.getDay() + 7) % 7;
d.setDate(d.getDate() + (untilSaturday === 0 ? 7 : untilSaturday));
d.setHours(10, 0, 0, 0);
return d;
}
function nextWeekSameTime() {
const d = new Date(base.value.getTime());
d.setDate(d.getDate() + 7);
return d;
}
const options = computed(() => {
const h1 = addMinutes(60);
const am = tomorrowAt(10);
const saturday = nextSaturdayMorning();
const week = nextWeekSameTime();
return [
{
key: 'h1' as const,
label: 'یک ساعت دیرتر',
time: formatClockFa(h1),
at: h1,
},
{
key: 'tomorrow-am' as const,
label: 'فردا صبح',
time: formatClockFa(am),
at: am,
},
{
key: 'next-saturday' as const,
label: 'شنبه آینده',
time: formatJalaliDayClock(saturday),
at: saturday,
},
{
key: 'next-week' as const,
label: 'هفته آینده',
time: formatJalaliDayClock(week),
at: week,
},
];
});
function toLocalInput(d: Date) {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
const customDate = computed(() => {
if (!customLocal.value) return null;
const d = new Date(customLocal.value);
return Number.isNaN(d.getTime()) ? null : d;
});
const customPreview = computed(() =>
customDate.value ? formatJalaliDayClock(customDate.value) : '',
);
function apply(at: Date) {
if (props.saving) return;
emit('submit', at.toISOString());
}
function pick(key: PostponeKey) {
if (key === 'custom') {
customOpen.value = true;
if (!customLocal.value) customLocal.value = toLocalInput(addMinutes(60));
return;
}
const at = options.value.find((item) => item.key === key)?.at;
if (at) apply(at);
}
function submitCustom() {
if (!customDate.value || props.saving) return;
apply(customDate.value);
}
function close() {
emit('close');
}
function onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape') {
ev.preventDefault();
close();
}
}
onMounted(() => {
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', onKeydown);
tickTimer = window.setInterval(() => {
nowTick.value = Date.now();
}, 15_000);
});
onBeforeUnmount(() => {
document.body.style.overflow = '';
window.removeEventListener('keydown', onKeydown);
if (tickTimer != null) window.clearInterval(tickTimer);
});
</script>
<template>
<Teleport to="body">
<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" />
<header class="head">
<h2 id="postpone-title">به تعویق انداختن</h2>
<button class="icon" type="button" aria-label="بستن" @click="close">
<UiIcon name="x" :size="20" />
</button>
</header>
<div class="body">
<div class="presets">
<button
v-for="item in options"
:key="item.key"
class="card"
type="button"
:disabled="saving"
@click="pick(item.key)"
>
<span class="label">{{ item.label }}</span>
<span class="when">{{ item.time }}</span>
</button>
</div>
<button
class="card custom"
type="button"
:class="{ on: customOpen }"
:disabled="saving"
@click="pick('custom')"
>
<span class="label">زمان دلخواه</span>
<span class="when">{{ customOpen && customPreview ? customPreview : 'تاریخ و ساعت' }}</span>
</button>
<div v-if="customOpen" class="custom-box">
<label class="picker">
<span>تاریخ و ساعت جدید</span>
<JalaliDateInput v-model="customLocal" mode="datetime" />
</label>
<button
class="confirm"
type="button"
:disabled="saving || !customDate"
@click="submitCustom"
>
{{ saving ? 'در حال ثبت…' : 'ثبت تعویق' }}
</button>
</div>
</div>
</section>
</div>
</Teleport>
</template>
<style scoped>
.layer {
position: fixed;
inset: 0;
z-index: 90;
display: flex;
justify-content: center;
align-items: flex-end;
}
.backdrop {
position: absolute;
inset: 0;
border: 0;
background: rgba(15, 23, 42, 0.28);
cursor: pointer;
}
.sheet {
position: relative;
width: 100%;
max-height: 92dvh;
background: #fff;
display: flex;
flex-direction: column;
font-family: IRANSans, Tahoma, sans-serif;
border-radius: 16px 16px 0 0;
box-shadow: 0 -12px 40px rgba(15, 23, 42, 0.12);
}
.handle {
width: 42px;
height: 4px;
margin: 0.55rem auto 0.15rem;
border-radius: 999px;
background: #d4d4d8;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.15rem 0.85rem 0.35rem;
}
.head h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #0f172a;
}
.icon {
width: 36px;
height: 36px;
border: 0;
background: none;
color: #64748b;
display: grid;
place-items: center;
cursor: pointer;
}
.body {
overflow: auto;
padding: 0.35rem 1rem calc(1rem + env(safe-area-inset-bottom));
display: grid;
gap: 0.55rem;
}
.presets {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.55rem;
}
.card {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 0.2rem;
min-height: 64px;
padding: 0.7rem 0.85rem;
border: 1px solid #e8eef3;
border-radius: var(--radius, 10px);
background: #fff;
font: inherit;
text-align: start;
cursor: pointer;
}
.card.on,
.card:hover:not(:disabled) {
border-color: #99f6e4;
background: #f0fdfa;
}
.card:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.label {
font-size: 0.92rem;
font-weight: 700;
color: #0f172a;
}
.when {
color: #0f766e;
font-size: 0.84rem;
font-weight: 600;
}
.custom {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.custom-box {
display: grid;
gap: 0.65rem;
}
.picker {
display: grid;
gap: 0.4rem;
color: #64748b;
font-size: 0.8rem;
}
.confirm {
border: 0;
background: #0f766e;
color: #fff;
border-radius: var(--radius, 10px);
min-height: 42px;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.confirm:disabled {
opacity: 0.55;
cursor: not-allowed;
}
@media (min-width: 900px) {
.layer {
align-items: center;
padding: 1.2rem;
}
.sheet {
max-width: 420px;
border-radius: 16px;
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22);
}
}
</style>