Add activity detail, Jalali pickers, and customer/user profiles.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,897 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
calendarDaysBetween,
|
||||
formatClockFa,
|
||||
formatJalaliDay,
|
||||
toFaDigits,
|
||||
} from '@/lib/jalali';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type { ActivityDto, ActivityType, OrganizationDto, OrgUserDto } from '@mento/shared';
|
||||
|
||||
const props = defineProps<{
|
||||
activity: ActivityDto;
|
||||
saving?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
submit: [text: string];
|
||||
}>();
|
||||
|
||||
const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
|
||||
call: 'phone',
|
||||
meeting: 'users',
|
||||
task: 'check',
|
||||
note: 'chat',
|
||||
};
|
||||
|
||||
const comment = ref('');
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const orgName = ref('سازمان');
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
const mentionOpen = ref(false);
|
||||
const voiceTip = ref(localStorage.getItem('mento.voiceTipSeen') !== '1');
|
||||
const voiceError = ref('');
|
||||
const recording = ref(false);
|
||||
const seconds = ref(0);
|
||||
const pullY = ref(0);
|
||||
|
||||
let recognition: SpeechRecognitionLike | null = null;
|
||||
let timer: number | null = null;
|
||||
let snapshot = '';
|
||||
let finals = '';
|
||||
let keepOnEnd = true;
|
||||
let pendingSubmit = false;
|
||||
let pulling = false;
|
||||
let pullStart = 0;
|
||||
|
||||
type SpeechRecognitionLike = {
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
abort: () => void;
|
||||
onresult: ((ev: SpeechRecognitionEventLike) => void) | null;
|
||||
onerror: ((ev: { error?: string }) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
};
|
||||
|
||||
type SpeechRecognitionEventLike = {
|
||||
resultIndex: number;
|
||||
results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }>;
|
||||
};
|
||||
|
||||
const subtitle = computed(() => {
|
||||
const a = props.activity;
|
||||
return a.deal?.title || a.company?.name || a.person?.fullName || '';
|
||||
});
|
||||
|
||||
const dueLabel = computed(() => {
|
||||
const due = props.activity.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 timerLabel = computed(() => {
|
||||
const m = String(Math.floor(seconds.value / 60)).padStart(2, '0');
|
||||
const s = String(seconds.value % 60).padStart(2, '0');
|
||||
return `${toFaDigits(m)}:${toFaDigits(s)}`;
|
||||
});
|
||||
|
||||
const canSubmit = computed(() => comment.value.trim().length > 0 && !props.saving);
|
||||
|
||||
function speechCtor(): (new () => SpeechRecognitionLike) | null {
|
||||
const w = window as Window & {
|
||||
SpeechRecognition?: new () => SpeechRecognitionLike;
|
||||
webkitSpeechRecognition?: new () => SpeechRecognitionLike;
|
||||
};
|
||||
return w.SpeechRecognition || w.webkitSpeechRecognition || null;
|
||||
}
|
||||
|
||||
function applyTranscript(interim = '') {
|
||||
const extra = `${finals}${interim}`.trim();
|
||||
comment.value = extra ? `${snapshot}${snapshot && !snapshot.endsWith(' ') ? ' ' : ''}${extra}` : snapshot;
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
stopTimer();
|
||||
seconds.value = 0;
|
||||
timer = window.setInterval(() => {
|
||||
seconds.value += 1;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timer != null) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecognition(keep: boolean) {
|
||||
keepOnEnd = keep;
|
||||
if (!recognition) {
|
||||
finishRecording(keep);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (keep) recognition.stop();
|
||||
else recognition.abort();
|
||||
} catch {
|
||||
finishRecording(keep);
|
||||
return;
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
if (recording.value) finishRecording(keepOnEnd);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function finishRecording(keep: boolean) {
|
||||
if (!recording.value && !recognition) return;
|
||||
stopTimer();
|
||||
recording.value = false;
|
||||
pullY.value = 0;
|
||||
recognition = null;
|
||||
if (!keep) {
|
||||
comment.value = snapshot;
|
||||
pendingSubmit = false;
|
||||
} else {
|
||||
applyTranscript('');
|
||||
}
|
||||
finals = '';
|
||||
if (pendingSubmit) {
|
||||
pendingSubmit = false;
|
||||
submit();
|
||||
}
|
||||
}
|
||||
|
||||
function startRecording() {
|
||||
voiceError.value = '';
|
||||
mentionOpen.value = false;
|
||||
if (voiceTip.value) {
|
||||
voiceTip.value = false;
|
||||
localStorage.setItem('mento.voiceTipSeen', '1');
|
||||
}
|
||||
const Ctor = speechCtor();
|
||||
if (!Ctor) {
|
||||
voiceError.value = 'تایپ صوتی در این مرورگر پشتیبانی نمیشود.';
|
||||
return;
|
||||
}
|
||||
snapshot = comment.value;
|
||||
finals = '';
|
||||
keepOnEnd = true;
|
||||
const rec = new Ctor();
|
||||
rec.lang = 'fa-IR';
|
||||
rec.continuous = true;
|
||||
rec.interimResults = true;
|
||||
rec.onresult = (ev) => {
|
||||
let interim = '';
|
||||
for (let i = ev.resultIndex; i < ev.results.length; i += 1) {
|
||||
const piece = ev.results[i];
|
||||
if (!piece) continue;
|
||||
const text = piece[0]?.transcript ?? '';
|
||||
if (piece.isFinal) finals += `${text} `;
|
||||
else interim += text;
|
||||
}
|
||||
applyTranscript(interim);
|
||||
};
|
||||
rec.onerror = (ev) => {
|
||||
if (ev.error === 'aborted' || ev.error === 'no-speech') return;
|
||||
voiceError.value = 'ضبط صدا ناموفق بود. دوباره تلاش کنید.';
|
||||
finishRecording(true);
|
||||
};
|
||||
rec.onend = () => {
|
||||
finishRecording(keepOnEnd);
|
||||
};
|
||||
recognition = rec;
|
||||
recording.value = true;
|
||||
startTimer();
|
||||
try {
|
||||
rec.start();
|
||||
} catch {
|
||||
voiceError.value = 'شروع ضبط صدا ممکن نشد.';
|
||||
finishRecording(false);
|
||||
}
|
||||
}
|
||||
|
||||
function discardRecording() {
|
||||
pendingSubmit = false;
|
||||
stopRecognition(false);
|
||||
}
|
||||
|
||||
function keepRecording() {
|
||||
pendingSubmit = false;
|
||||
stopRecognition(true);
|
||||
}
|
||||
|
||||
function sendRecording() {
|
||||
pendingSubmit = true;
|
||||
stopRecognition(true);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (recording.value) keepRecording();
|
||||
const text = comment.value.trim();
|
||||
if (!text || props.saving) return;
|
||||
emit('submit', text);
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (recording.value) discardRecording();
|
||||
emit('close');
|
||||
}
|
||||
|
||||
function insertMention(name: string) {
|
||||
const token = `@${name} `;
|
||||
const el = textareaRef.value;
|
||||
if (!el) {
|
||||
comment.value += token;
|
||||
mentionOpen.value = false;
|
||||
return;
|
||||
}
|
||||
const start = el.selectionStart ?? comment.value.length;
|
||||
const end = el.selectionEnd ?? start;
|
||||
comment.value = `${comment.value.slice(0, start)}${token}${comment.value.slice(end)}`;
|
||||
mentionOpen.value = false;
|
||||
void nextTick(() => {
|
||||
el.focus();
|
||||
const pos = start + token.length;
|
||||
el.setSelectionRange(pos, pos);
|
||||
});
|
||||
}
|
||||
|
||||
function onKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === 'Escape') {
|
||||
ev.preventDefault();
|
||||
if (mentionOpen.value) {
|
||||
mentionOpen.value = false;
|
||||
return;
|
||||
}
|
||||
if (recording.value) {
|
||||
discardRecording();
|
||||
return;
|
||||
}
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function onPullStart(ev: PointerEvent) {
|
||||
if (!recording.value) return;
|
||||
const target = ev.target as HTMLElement | null;
|
||||
if (target?.closest('button')) return;
|
||||
pulling = true;
|
||||
pullStart = ev.clientY;
|
||||
pullY.value = 0;
|
||||
(ev.currentTarget as HTMLElement | null)?.setPointerCapture?.(ev.pointerId);
|
||||
}
|
||||
|
||||
function onPullMove(ev: PointerEvent) {
|
||||
if (!pulling) return;
|
||||
pullY.value = Math.max(0, ev.clientY - pullStart);
|
||||
}
|
||||
|
||||
function onPullEnd() {
|
||||
if (!pulling) return;
|
||||
const shouldCancel = pullY.value > 72;
|
||||
pulling = false;
|
||||
pullY.value = 0;
|
||||
if (shouldCancel) discardRecording();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
await nextTick();
|
||||
textareaRef.value?.focus();
|
||||
try {
|
||||
const [{ data: org }, usersRes] = await Promise.all([
|
||||
api.get<OrganizationDto>('/organizations/current'),
|
||||
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
|
||||
]);
|
||||
if (org.name) orgName.value = org.name;
|
||||
users.value = usersRes.data;
|
||||
if (!users.value.length && props.activity.assignee) {
|
||||
users.value = [
|
||||
{
|
||||
id: props.activity.assignee.id,
|
||||
email: '',
|
||||
fullName: props.activity.assignee.fullName,
|
||||
role: 'sales',
|
||||
createdAt: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
} catch {
|
||||
orgName.value = 'سازمان';
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.style.overflow = '';
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
pendingSubmit = false;
|
||||
stopTimer();
|
||||
if (recognition) {
|
||||
try {
|
||||
recognition.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
recognition = null;
|
||||
}
|
||||
recording.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="layer" role="presentation">
|
||||
<button class="backdrop" type="button" aria-label="بستن" @click="close" />
|
||||
<section
|
||||
class="sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="comment-sheet-title"
|
||||
>
|
||||
<div class="handle" aria-hidden="true" />
|
||||
|
||||
<header class="head">
|
||||
<button class="icon" type="button" aria-label="ثبت نظر" :disabled="!canSubmit" @click="submit">
|
||||
<UiIcon name="check-plain" :size="22" />
|
||||
</button>
|
||||
<h2 id="comment-sheet-title">ثبت نظر</h2>
|
||||
<button class="icon" type="button" aria-label="بستن" @click="close">
|
||||
<UiIcon name="x" :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<article class="ctx">
|
||||
<div class="ctx-copy">
|
||||
<h3>{{ activity.title }}</h3>
|
||||
<p v-if="subtitle" class="sub">
|
||||
<UiIcon name="building" :size="14" />
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
<div class="meta">
|
||||
<span>
|
||||
<UiIcon name="user" :size="14" />
|
||||
{{ activity.assignee?.fullName || 'بدون مسئول' }}
|
||||
</span>
|
||||
<i />
|
||||
<span>
|
||||
<UiIcon name="calendar" :size="14" />
|
||||
{{ dueLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="type" :class="activity.type">
|
||||
<UiIcon :name="typeIcon[activity.type]" :size="22" />
|
||||
</span>
|
||||
</article>
|
||||
|
||||
<div class="editor">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="comment"
|
||||
:disabled="recording"
|
||||
rows="6"
|
||||
placeholder="متن خود را بنویسید..."
|
||||
/>
|
||||
<div class="tools">
|
||||
<div class="tool-wrap">
|
||||
<button
|
||||
class="tool"
|
||||
type="button"
|
||||
aria-label="منشن"
|
||||
:disabled="recording"
|
||||
@click="mentionOpen = !mentionOpen"
|
||||
>
|
||||
@
|
||||
</button>
|
||||
<ul v-if="mentionOpen" class="mentions">
|
||||
<li v-if="!users.length" class="empty">کاربری برای منشن یافت نشد.</li>
|
||||
<li v-for="user in users" :key="user.id">
|
||||
<button type="button" @click="insertMention(user.fullName)">
|
||||
{{ user.fullName }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tool-wrap">
|
||||
<p v-if="voiceTip && !recording" class="tip">تایپ صوتی را امتحان کنید! 🎙️✨</p>
|
||||
<button
|
||||
class="tool mic"
|
||||
type="button"
|
||||
aria-label="تایپ صوتی"
|
||||
:class="{ on: recording }"
|
||||
@click="recording ? keepRecording() : startRecording()"
|
||||
>
|
||||
<UiIcon name="mic" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="voiceError" class="voice-err">{{ voiceError }}</p>
|
||||
|
||||
<p class="notice">
|
||||
<UiIcon name="lightbulb" :size="18" />
|
||||
یادداشت شما فقط برای اعضای تیم و مدیران {{ orgName }} قابل مشاهده خواهد بود.
|
||||
</p>
|
||||
|
||||
<div v-if="!recording" class="actions">
|
||||
<button class="act primary" type="button" :disabled="!canSubmit" @click="submit">
|
||||
ثبت نظر
|
||||
</button>
|
||||
<button class="act ghost" type="button" :disabled="saving" @click="close">انصراف</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="recorder"
|
||||
:style="{ transform: pullY ? `translateY(${pullY}px)` : undefined }"
|
||||
@pointerdown="onPullStart"
|
||||
@pointermove="onPullMove"
|
||||
@pointerup="onPullEnd"
|
||||
@pointercancel="onPullEnd"
|
||||
>
|
||||
<div class="rec-top">
|
||||
<strong>در حال ضبط صدا...</strong>
|
||||
<span>{{ timerLabel }}</span>
|
||||
</div>
|
||||
<div class="rec-controls">
|
||||
<button class="rec-side" type="button" aria-label="ارسال نظر" @click="sendRecording">
|
||||
<UiIcon name="send" :size="18" />
|
||||
</button>
|
||||
<button class="rec-stop" type="button" aria-label="توقف ضبط" @click="keepRecording">
|
||||
<UiIcon name="stop" :size="22" />
|
||||
</button>
|
||||
<button class="rec-side" type="button" aria-label="حذف ضبط" @click="discardRecording">
|
||||
<UiIcon name="trash" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="pull">
|
||||
برای لغو، بیرون بکشید
|
||||
<UiIcon name="chevron" :size="14" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: IRANSans, Tahoma, sans-serif;
|
||||
}
|
||||
|
||||
.handle {
|
||||
width: 42px;
|
||||
height: 4px;
|
||||
margin: 0.55rem auto 0.15rem;
|
||||
border-radius: 999px;
|
||||
background: #d4d4d8;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.7rem 0.45rem;
|
||||
}
|
||||
|
||||
.head h2 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0.35rem 1rem calc(1rem + env(safe-area-inset-bottom));
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.ctx {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.9rem 0.95rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.type {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.type.call {
|
||||
background: #0d9488;
|
||||
}
|
||||
|
||||
.type.meeting {
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
.type.task {
|
||||
background: #ea580c;
|
||||
}
|
||||
|
||||
.type.note {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.ctx-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ctx-copy h3 {
|
||||
margin: 0.1rem 0 0;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.sub,
|
||||
.meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 0.28rem 0 0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
margin-top: 0.7rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.meta i {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.editor {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
min-height: 11.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor textarea {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 8.5rem;
|
||||
border: 0;
|
||||
resize: none;
|
||||
padding: 0.9rem 0.95rem 0.2rem;
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.9;
|
||||
color: #0f172a;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.editor textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editor textarea::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
padding: 0.35rem 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.tool-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tool {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool.mic {
|
||||
color: #0d9488;
|
||||
}
|
||||
|
||||
.tool.on,
|
||||
.tool.mic:hover {
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.tip {
|
||||
position: absolute;
|
||||
inset-inline-end: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
padding: 0.42rem 0.65rem;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.tip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline-end: 14px;
|
||||
top: 100%;
|
||||
border: 6px solid transparent;
|
||||
border-top-color: #111827;
|
||||
}
|
||||
|
||||
.mentions {
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
bottom: calc(100% + 6px);
|
||||
margin: 0;
|
||||
padding: 0.3rem;
|
||||
list-style: none;
|
||||
min-width: 11.5rem;
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.16);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.mentions button,
|
||||
.mentions .empty {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: none;
|
||||
text-align: start;
|
||||
font: inherit;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
color: #0f172a;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mentions .empty {
|
||||
color: #94a3b8;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mentions button:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.voice-err {
|
||||
margin: -0.35rem 0 0;
|
||||
color: #b91c1c;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.72rem 0.8rem;
|
||||
border-radius: 12px;
|
||||
background: #ecfdf5;
|
||||
color: #0f766e;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.notice :deep(.ico) {
|
||||
margin-top: 0.12rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.act {
|
||||
min-height: 48px;
|
||||
border-radius: 14px;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.act.primary {
|
||||
border: 0;
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.act.ghost {
|
||||
border: 1.5px solid #14b8a6;
|
||||
background: #fff;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.act:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.recorder {
|
||||
border: 1.5px dashed #cbd5e1;
|
||||
border-radius: 16px;
|
||||
padding: 0.95rem 0.85rem 0.7rem;
|
||||
text-align: center;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.rec-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #334155;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.rec-top strong {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.rec-top span {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.rec-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 1rem 1.4rem 0.55rem;
|
||||
}
|
||||
|
||||
.rec-side {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rec-stop {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
box-shadow: 0 10px 24px rgba(20, 184, 166, 0.35);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pull {
|
||||
margin: 0.35rem 0 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.pull :deep(.ico) {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.layer {
|
||||
background: rgba(15, 23, 42, 0.28);
|
||||
align-items: center;
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
position: relative;
|
||||
max-width: 480px;
|
||||
height: min(92dvh, 860px);
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,458 @@
|
||||
<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 = 'm15' | 'm30' | 'h1' | 'tomorrow-am' | 'tomorrow-pm' | 'custom';
|
||||
|
||||
const props = defineProps<{
|
||||
dueAt?: string | null;
|
||||
saving?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
submit: [iso: string];
|
||||
}>();
|
||||
|
||||
const nowTick = ref(Date.now());
|
||||
const selected = ref<PostponeKey | null>(null);
|
||||
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;
|
||||
}
|
||||
|
||||
const options = computed(() => {
|
||||
const m15 = addMinutes(15);
|
||||
const m30 = addMinutes(30);
|
||||
const h1 = addMinutes(60);
|
||||
const am = tomorrowAt(10);
|
||||
const pm = tomorrowAt(13);
|
||||
return [
|
||||
{
|
||||
key: 'm15' as const,
|
||||
icon: 'refresh' as const,
|
||||
label: 'یک ربع دیرتر',
|
||||
time: formatClockFa(m15),
|
||||
at: m15,
|
||||
},
|
||||
{
|
||||
key: 'm30' as const,
|
||||
icon: 'refresh' as const,
|
||||
label: 'نیم ساعت دیرتر',
|
||||
time: formatClockFa(m30),
|
||||
at: m30,
|
||||
},
|
||||
{
|
||||
key: 'h1' as const,
|
||||
icon: 'refresh' as const,
|
||||
label: 'یک ساعت دیرتر',
|
||||
time: formatClockFa(h1),
|
||||
at: h1,
|
||||
},
|
||||
{
|
||||
key: 'tomorrow-am' as const,
|
||||
icon: 'calendar-sun' as const,
|
||||
label: 'فردا صبح',
|
||||
time: `فردا ${formatClockFa(am)}`,
|
||||
at: am,
|
||||
},
|
||||
{
|
||||
key: 'tomorrow-pm' as const,
|
||||
icon: 'calendar-sun' as const,
|
||||
label: 'فردا بعد از ظهر',
|
||||
time: `فردا ${formatClockFa(pm)}`,
|
||||
at: pm,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
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) : 'تاریخ و ساعت را انتخاب کنید',
|
||||
);
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (props.saving || !selected.value) return false;
|
||||
if (selected.value === 'custom') return Boolean(customDate.value);
|
||||
return true;
|
||||
});
|
||||
|
||||
function chosenDate(): Date | null {
|
||||
if (selected.value === 'custom') return customDate.value;
|
||||
return options.value.find((item) => item.key === selected.value)?.at ?? null;
|
||||
}
|
||||
|
||||
function pick(key: PostponeKey) {
|
||||
selected.value = key;
|
||||
if (key === 'custom' && !customLocal.value) {
|
||||
customLocal.value = toLocalInput(addMinutes(60));
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const at = chosenDate();
|
||||
if (!at || props.saving) return;
|
||||
emit('submit', at.toISOString());
|
||||
}
|
||||
|
||||
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">
|
||||
<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">
|
||||
<button class="icon" type="button" aria-label="تأیید تعویق" :disabled="!canSubmit" @click="submit">
|
||||
<UiIcon name="check-plain" :size="22" />
|
||||
</button>
|
||||
<div class="titles">
|
||||
<h2 id="postpone-title">به تعویق انداختن فعالیت</h2>
|
||||
</div>
|
||||
<button class="icon" type="button" aria-label="بستن" @click="close">
|
||||
<UiIcon name="x" :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="lead">زمان جدید برای این فعالیت را انتخاب کنید.</p>
|
||||
|
||||
<div class="body">
|
||||
<button
|
||||
v-for="item in options"
|
||||
:key="item.key"
|
||||
class="card"
|
||||
type="button"
|
||||
:class="{ on: selected === item.key }"
|
||||
:disabled="saving"
|
||||
@click="pick(item.key)"
|
||||
>
|
||||
<span class="label">{{ item.label }}</span>
|
||||
<span class="aside">
|
||||
<span class="ico-box">
|
||||
<UiIcon :name="item.icon" :size="20" />
|
||||
</span>
|
||||
<span class="when">{{ item.time }}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="card custom"
|
||||
type="button"
|
||||
:class="{ on: selected === 'custom' }"
|
||||
:disabled="saving"
|
||||
@click="pick('custom')"
|
||||
>
|
||||
<span class="copy">
|
||||
<strong>تنظیم زمان دلخواه</strong>
|
||||
<small>انتخاب تاریخ و ساعت</small>
|
||||
</span>
|
||||
<span class="aside">
|
||||
<span class="ico-box">
|
||||
<UiIcon name="calendar-edit" :size="20" />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div v-if="selected === 'custom'" class="custom-box">
|
||||
<label class="picker">
|
||||
<span>تاریخ و ساعت جدید</span>
|
||||
<JalaliDateInput v-model="customLocal" mode="datetime" />
|
||||
</label>
|
||||
<p class="preview">{{ customPreview }}</p>
|
||||
</div>
|
||||
|
||||
<p class="notice">
|
||||
<UiIcon name="lightbulb" :size="18" />
|
||||
با تعویق انداختن، این فعالیت در زمان جدید به لیست فعالیتهای شما منتقل میشود.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: IRANSans, Tahoma, sans-serif;
|
||||
}
|
||||
|
||||
.handle {
|
||||
width: 42px;
|
||||
height: 4px;
|
||||
margin: 0.55rem auto 0.15rem;
|
||||
border-radius: 999px;
|
||||
background: #d4d4d8;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
align-items: center;
|
||||
padding: 0.1rem 0.7rem 0.15rem;
|
||||
}
|
||||
|
||||
.titles h2,
|
||||
.lead {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.titles h2 {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.lead {
|
||||
color: #94a3b8;
|
||||
font-size: 0.82rem;
|
||||
padding: 0 1.2rem 0.55rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0.15rem 1rem calc(1rem + env(safe-area-inset-bottom));
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
min-height: 72px;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid #e8eef3;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card.on {
|
||||
border-color: #99f6e4;
|
||||
background: #f0fdfa;
|
||||
box-shadow: 0 0 0 1px #99f6e4;
|
||||
}
|
||||
|
||||
.card:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.label,
|
||||
.copy strong {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.copy {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.copy small {
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.aside {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.28rem;
|
||||
min-width: 3.6rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ico-box {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: #e6f7f4;
|
||||
color: #0d9488;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.when {
|
||||
color: #0d9488;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.custom-box {
|
||||
border: 1px solid #e8eef3;
|
||||
border-radius: 16px;
|
||||
padding: 0.85rem 0.9rem;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.picker {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.picker input {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 0.45rem 0.7rem;
|
||||
font: inherit;
|
||||
color: #0f172a;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.preview {
|
||||
margin: 0;
|
||||
color: #0f766e;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0.35rem 0 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.55rem;
|
||||
padding: 0.85rem 0.9rem;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.notice :deep(.ico) {
|
||||
margin-top: 0.15rem;
|
||||
color: #0d9488;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.layer {
|
||||
background: rgba(15, 23, 42, 0.28);
|
||||
align-items: center;
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
position: relative;
|
||||
max-width: 480px;
|
||||
height: min(92dvh, 860px);
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user