Add activity detail, Jalali pickers, and customer/user profiles.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 21:29:00 +03:30
co-authored by Cursor
parent 574feedfa5
commit f884c60cab
39 changed files with 8255 additions and 441 deletions
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import type {
CustomFieldDefinitionDto,
CustomFieldEntity,
@@ -104,12 +105,11 @@ onMounted(load);
"
/>
<input
<JalaliDateInput
v-else-if="field.fieldType === 'date'"
:id="`cf-${field.key}`"
type="date"
:value="String(displayValue(field.key))"
@input="setValue(field.key, ($event.target as HTMLInputElement).value)"
:model-value="String(displayValue(field.key))"
@update:model-value="setValue(field.key, $event || null)"
/>
<input
@@ -160,8 +160,7 @@ h3 {
select,
input[type='text'],
input[type='number'],
input[type='date'] {
input[type='number'] {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.8rem 0.95rem;
+575
View File
@@ -0,0 +1,575 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import {
addJalaliMonths,
dateToJalali,
formatJalaliNumeric,
formatLocalDateInput,
formatLocalDateTimeInput,
jalaliMonthLength,
jalaliMonthName,
jalaliToDate,
parseLocalInput,
toFaDigits,
} from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
const model = defineModel<string>({ default: '' });
const props = withDefaults(
defineProps<{
mode?: 'date' | 'datetime' | 'time';
id?: string;
required?: boolean;
disabled?: boolean;
placeholder?: string;
clearable?: boolean;
icon?: 'calendar' | 'clock' | 'bell';
}>(),
{
mode: 'date',
required: false,
disabled: false,
clearable: true,
icon: undefined,
},
);
const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] as const;
const open = ref(false);
const triggerRef = ref<HTMLButtonElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const top = ref(0);
const left = ref(0);
const cursor = ref(dateToJalali(new Date()));
const draftHour = ref(9);
const draftMinute = ref(0);
type Cell = {
key: string;
jy: number;
jm: number;
jd: number;
inMonth: boolean;
isToday: boolean;
isSelected: boolean;
};
const parsed = computed(() => (props.mode === 'time' ? null : parseLocalInput(model.value)));
const triggerIcon = computed(() => {
if (props.icon) return props.icon;
if (props.mode === 'time') return 'clock';
if (props.icon === 'bell') return 'bell';
return 'calendar';
});
const display = computed(() => {
if (props.mode === 'time') {
const match = /^(\d{1,2}):(\d{2})$/.exec(model.value);
if (!match) return '';
return `${toFaDigits(match[1].padStart(2, '0'))}:${toFaDigits(match[2])}`;
}
if (!parsed.value) return '';
const date = formatJalaliNumeric(parsed.value);
if (props.mode === 'date') return date;
const hh = String(parsed.value.getHours()).padStart(2, '0');
const mm = String(parsed.value.getMinutes()).padStart(2, '0');
return `${date} ${toFaDigits(hh)}:${toFaDigits(mm)}`;
});
const emptyLabel = computed(
() =>
props.placeholder ||
(props.mode === 'datetime'
? 'انتخاب تاریخ و ساعت'
: props.mode === 'time'
? 'انتخاب ساعت'
: 'انتخاب تاریخ'),
);
const monthTitle = computed(
() => `${jalaliMonthName(cursor.value.jm)} ${toFaDigits(cursor.value.jy)}`,
);
const hours = Array.from({ length: 24 }, (_, i) => i);
const minutes = computed(() => {
const steps = Array.from({ length: 12 }, (_, i) => i * 5);
if (!steps.includes(draftMinute.value)) {
return [...steps, draftMinute.value].sort((a, b) => a - b);
}
return steps;
});
const cells = computed<Cell[]>(() => {
const { jy, jm } = cursor.value;
const first = jalaliToDate(jy, jm, 1);
const firstDow = (first.getDay() + 1) % 7;
const today = dateToJalali(new Date());
const selected = parsed.value ? dateToJalali(parsed.value) : null;
const out: Cell[] = [];
const prev = addJalaliMonths(jy, jm, -1);
const prevLen = jalaliMonthLength(prev.jy, prev.jm);
for (let i = 0; i < firstDow; i += 1) {
const jd = prevLen - firstDow + i + 1;
out.push(makeCell(prev.jy, prev.jm, jd, false, today, selected));
}
const len = jalaliMonthLength(jy, jm);
for (let jd = 1; jd <= len; jd += 1) {
out.push(makeCell(jy, jm, jd, true, today, selected));
}
const next = addJalaliMonths(jy, jm, 1);
let n = 1;
while (out.length % 7 !== 0) {
out.push(makeCell(next.jy, next.jm, n, false, today, selected));
n += 1;
}
return out;
});
function makeCell(
jy: number,
jm: number,
jd: number,
inMonth: boolean,
today: { jy: number; jm: number; jd: number },
selected: { jy: number; jm: number; jd: number } | null,
): Cell {
return {
key: `${jy}-${jm}-${jd}`,
jy,
jm,
jd,
inMonth,
isToday: today.jy === jy && today.jm === jm && today.jd === jd,
isSelected: Boolean(selected && selected.jy === jy && selected.jm === jm && selected.jd === jd),
};
}
function emitDate(d: Date) {
model.value = props.mode === 'datetime' ? formatLocalDateTimeInput(d) : formatLocalDateInput(d);
}
function emitTime() {
const pad = (n: number) => String(n).padStart(2, '0');
model.value = `${pad(draftHour.value)}:${pad(draftMinute.value)}`;
}
function syncDraftFrom(d: Date | null) {
if (props.mode === 'time') {
const match = /^(\d{1,2}):(\d{2})$/.exec(model.value);
draftHour.value = match ? Number(match[1]) : 10;
draftMinute.value = match ? Number(match[2]) : 30;
return;
}
const source = d ?? new Date();
const j = dateToJalali(source);
cursor.value = { jy: j.jy, jm: j.jm };
draftHour.value = props.mode === 'datetime' ? source.getHours() : 9;
draftMinute.value = props.mode === 'datetime' ? source.getMinutes() : 0;
}
function place() {
const trigger = triggerRef.value;
const panel = panelRef.value;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width = panel?.offsetWidth || 320;
const height = panel?.offsetHeight || 360;
let nextLeft = rect.left;
if (getComputedStyle(document.documentElement).direction === 'rtl') {
nextLeft = rect.right - width;
}
nextLeft = Math.min(Math.max(8, nextLeft), window.innerWidth - width - 8);
let nextTop = rect.bottom + 8;
if (nextTop + height > window.innerHeight - 8) {
nextTop = Math.max(8, rect.top - height - 8);
}
left.value = nextLeft;
top.value = nextTop;
}
function onWindowChange() {
if (open.value) place();
}
async function show() {
if (props.disabled) return;
syncDraftFrom(parsed.value);
open.value = true;
await nextTick();
place();
await nextTick();
place();
}
function hide() {
open.value = false;
}
function toggle() {
if (open.value) hide();
else void show();
}
function goMonth(delta: number) {
const next = addJalaliMonths(cursor.value.jy, cursor.value.jm, delta);
cursor.value = next;
void nextTick(place);
}
function pickDay(cell: Cell) {
if (!cell.inMonth) {
cursor.value = { jy: cell.jy, jm: cell.jm };
}
const d = jalaliToDate(cell.jy, cell.jm, cell.jd);
if (props.mode === 'datetime') {
d.setHours(draftHour.value, draftMinute.value, 0, 0);
emitDate(d);
return;
}
emitDate(d);
hide();
}
function applyTime() {
if (props.mode === 'time') {
emitTime();
return;
}
const source = parsed.value ?? jalaliToDate(cursor.value.jy, cursor.value.jm, 1);
const j = dateToJalali(source);
const d = jalaliToDate(j.jy, j.jm, j.jd);
d.setHours(draftHour.value, draftMinute.value, 0, 0);
emitDate(d);
}
function pickToday() {
const d = new Date();
if (props.mode === 'time') {
draftHour.value = d.getHours();
draftMinute.value = d.getMinutes();
emitTime();
return;
}
if (props.mode === 'date') {
d.setHours(0, 0, 0, 0);
}
syncDraftFrom(d);
emitDate(d);
if (props.mode === 'date') hide();
}
function clear() {
model.value = '';
hide();
}
function onDocPointer(ev: PointerEvent) {
const target = ev.target as Node | null;
if (!target) return;
if (triggerRef.value?.contains(target) || panelRef.value?.contains(target)) return;
hide();
}
function onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape' && open.value) {
ev.stopPropagation();
ev.preventDefault();
hide();
}
}
watch(open, (value) => {
if (value) {
window.addEventListener('resize', onWindowChange);
window.addEventListener('scroll', onWindowChange, true);
document.addEventListener('pointerdown', onDocPointer, true);
window.addEventListener('keydown', onKeydown, true);
return;
}
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
document.removeEventListener('pointerdown', onDocPointer, true);
window.removeEventListener('keydown', onKeydown, true);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
document.removeEventListener('pointerdown', onDocPointer, true);
window.removeEventListener('keydown', onKeydown, true);
});
</script>
<template>
<div class="wrap" :class="{ disabled }">
<button
:id="id"
ref="triggerRef"
class="trigger"
type="button"
:disabled="disabled"
:aria-expanded="open"
:aria-required="required || undefined"
@click="toggle"
>
<span :class="{ empty: !display }">{{ display || emptyLabel }}</span>
<UiIcon :name="triggerIcon" :size="18" />
</button>
<Teleport to="body">
<div
v-if="open"
id="jalali-picker"
ref="panelRef"
class="panel"
role="dialog"
:style="{ top: `${top}px`, left: `${left}px` }"
>
<header v-if="mode !== 'time'" class="nav">
<button type="button" class="nav-btn" aria-label="ماه بعد" @click="goMonth(1)">
<UiIcon name="chevron" :size="16" />
</button>
<strong>{{ monthTitle }}</strong>
<button type="button" class="nav-btn next" aria-label="ماه قبل" @click="goMonth(-1)">
<UiIcon name="chevron" :size="16" />
</button>
</header>
<template v-if="mode !== 'time'">
<div class="week">
<span v-for="day in WEEKDAYS" :key="day">{{ day }}</span>
</div>
<div class="grid">
<button
v-for="cell in cells"
:key="cell.key"
type="button"
class="day"
:class="{ mute: !cell.inMonth, today: cell.isToday, on: cell.isSelected }"
@click="pickDay(cell)"
>
{{ toFaDigits(cell.jd) }}
</button>
</div>
</template>
<div v-if="mode === 'datetime' || mode === 'time'" class="time">
<label>
ساعت
<select v-model.number="draftHour" @change="applyTime">
<option v-for="h in hours" :key="h" :value="h">{{ toFaDigits(String(h).padStart(2, '0')) }}</option>
</select>
</label>
<label>
دقیقه
<select v-model.number="draftMinute" @change="applyTime">
<option v-for="m in minutes" :key="m" :value="m">{{ toFaDigits(String(m).padStart(2, '0')) }}</option>
</select>
</label>
</div>
<footer class="foot">
<button type="button" class="link" @click="pickToday">امروز</button>
<button
v-if="clearable && !required && model"
type="button"
class="link"
@click="clear"
>
پاک کردن
</button>
<button type="button" class="done" @click="hide">تأیید</button>
</footer>
</div>
</Teleport>
</div>
</template>
<style scoped>
.wrap {
position: relative;
width: 100%;
min-width: 0;
}
.trigger {
width: 100%;
min-height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
border: 1px solid var(--line, #e2e8f0);
border-radius: 12px;
padding: 0.7rem 0.85rem;
background: #fff;
color: #0f172a;
font: inherit;
text-align: start;
cursor: pointer;
}
.trigger:focus {
outline: 2px solid var(--accent-soft, #ccfbf1);
border-color: var(--accent, #0d9488);
}
.trigger span {
min-width: 0;
}
.trigger .empty {
color: #94a3b8;
}
.trigger :deep(.ico) {
color: #0d9488;
flex-shrink: 0;
}
.disabled .trigger {
opacity: 0.55;
cursor: not-allowed;
}
.panel {
position: fixed;
z-index: 120;
width: min(20.5rem, calc(100vw - 16px));
background: #fff;
border-radius: 16px;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
padding: 0.75rem 0.8rem 0.7rem;
font-family: IRANSans, Tahoma, sans-serif;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.55rem;
}
.nav strong {
font-size: 0.92rem;
}
.nav-btn {
width: 32px;
height: 32px;
border: 0;
border-radius: 8px;
background: #f1f5f9;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.nav-btn.next :deep(.ico) {
transform: scaleX(-1);
}
.week,
.grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.15rem;
}
.week span {
text-align: center;
color: #94a3b8;
font-size: 0.72rem;
padding-bottom: 0.25rem;
}
.day {
height: 34px;
border: 0;
border-radius: 9px;
background: none;
color: #0f172a;
font: inherit;
cursor: pointer;
}
.day.mute {
color: #cbd5e1;
}
.day.today {
box-shadow: inset 0 0 0 1px #99f6e4;
}
.day.on,
.day:hover {
background: #0d9488;
color: #fff;
}
.day.mute:hover {
background: #f1f5f9;
color: #64748b;
}
.time {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin-top: 0.65rem;
}
.time label {
display: grid;
gap: 0.28rem;
color: #64748b;
font-size: 0.75rem;
}
.time select {
width: 100%;
min-height: 38px;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0.3rem 0.5rem;
background: #fff;
font: inherit;
}
.foot {
display: flex;
align-items: center;
gap: 0.55rem;
margin-top: 0.7rem;
}
.link,
.done {
border: 0;
background: none;
font: inherit;
cursor: pointer;
}
.link {
color: #0f766e;
font-size: 0.8rem;
font-weight: 700;
}
.done {
margin-inline-start: auto;
background: #14b8a6;
color: #fff;
border-radius: 10px;
padding: 0.4rem 0.8rem;
font-weight: 800;
font-size: 0.8rem;
}
</style>
@@ -2,6 +2,7 @@
import { computed, reactive, ref, watch } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import type { NoteAiPayloadDto, NoteAiSuggestionDto } from '@mento/shared';
const props = defineProps<{
@@ -190,7 +191,7 @@ function formatPayloadSummary(value: NoteAiPayloadDto | null) {
کار بعدی
</label>
<input v-model="nextTitle" type="text" placeholder="عنوان کار بعدی" />
<input v-model="nextDueAt" type="datetime-local" />
<JalaliDateInput v-model="nextDueAt" mode="datetime" />
</div>
<div v-if="memoryItems.length" class="block">
@@ -275,7 +276,6 @@ header {
}
input[type='text'],
input[type='datetime-local'],
textarea {
border: 1px solid var(--line);
border-radius: 10px;
+131 -1
View File
@@ -12,6 +12,7 @@ defineProps<{
| 'sparkle'
| 'bell'
| 'calendar'
| 'calendar-plus'
| 'mail'
| 'megaphone'
| 'send'
@@ -19,7 +20,31 @@ defineProps<{
| 'warning'
| 'trend'
| 'phone'
| 'trophy';
| 'trophy'
| 'chat'
| 'dots'
| 'dots-v'
| 'document'
| 'bag'
| 'clock'
| 'user-plus'
| 'plus'
| 'chevron'
| 'star'
| 'filter'
| 'lightbulb'
| 'building'
| 'user'
| 'flag'
| 'refresh'
| 'x'
| 'mic'
| 'trash'
| 'stop'
| 'check-plain'
| 'calendar-sun'
| 'calendar-edit'
| 'minus';
size?: number;
}>();
</script>
@@ -88,6 +113,22 @@ defineProps<{
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
</template>
<template v-else-if="name === 'calendar-plus'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
<path d="M12 13v5M9.5 15.5h5" />
</template>
<template v-else-if="name === 'calendar-sun'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
<circle cx="12" cy="15.2" r="2.1" />
<path d="M12 11.4v.7M12 18.3v.7M8.9 15.2h.7M14.4 15.2h.7M9.6 12.8l.5.5M13.9 17.1l.5.5M13.9 12.8l-.5.5M9.6 17.1l-.5.5" />
</template>
<template v-else-if="name === 'calendar-edit'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
<path d="M11.2 18.2 16.6 12.8l1.5 1.5-5.4 5.4H11.2v-1.5Z" />
</template>
<template v-else-if="name === 'mail'">
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m4 7 8 6 8-6" />
@@ -122,6 +163,95 @@ defineProps<{
<path d="M7 6H4.5A2.5 2.5 0 0 0 7 8.5" />
<path d="M17 6h2.5A2.5 2.5 0 0 1 17 8.5" />
</template>
<template v-else-if="name === 'chat'">
<path d="M5 18.5 6.2 15A7.5 7.5 0 1 1 9 19.2L5 18.5Z" />
<path d="M8.5 11h7M8.5 14h4.5" />
</template>
<template v-else-if="name === 'dots'">
<circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'dots-v'">
<circle cx="12" cy="5" r="1.4" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="12" cy="19" r="1.4" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'document'">
<path d="M7 4.5h7.5L19 9v10.5a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 6 19.5v-14A1.5 1.5 0 0 1 7.5 4.5H7Z" />
<path d="M14.5 4.5V9H19" />
<path d="M9 13h6M9 16.5h4" />
</template>
<template v-else-if="name === 'bag'">
<path d="M6.5 8h11l1 12.5H5.5L6.5 8Z" />
<path d="M9 8V6.5A3 3 0 0 1 12 3.5 3 3 0 0 1 15 6.5V8" />
</template>
<template v-else-if="name === 'clock'">
<circle cx="12" cy="12" r="8" />
<path d="M12 8v4.2l2.8 1.8" />
</template>
<template v-else-if="name === 'user-plus'">
<circle cx="10" cy="8" r="3" />
<path d="M4 19c.7-3 2.6-4.5 6-4.5s5.3 1.5 6 4.5" />
<path d="M17 8v5M14.5 10.5h5" />
</template>
<template v-else-if="name === 'plus'">
<path d="M12 6v12M6 12h12" />
</template>
<template v-else-if="name === 'chevron'">
<path d="m9 6 6 6-6 6" />
</template>
<template v-else-if="name === 'star'">
<path d="m12 3.4 2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 15.8 7.2 18.3l.9-5.4-3.9-3.8 5.4-.8L12 3.4Z" />
</template>
<template v-else-if="name === 'filter'">
<path d="M4 6h16M7 12h10M10 18h4" />
</template>
<template v-else-if="name === 'lightbulb'">
<path d="M9 18h6" />
<path d="M10 21h4" />
<path d="M8 14.2A5.2 5.2 0 1 1 16 14.2C16 16 14.7 17 14 18H10c-.7-1-2-2-2-3.8Z" />
</template>
<template v-else-if="name === 'building'">
<path d="M5 21V5.5A1.5 1.5 0 0 1 6.5 4h7A1.5 1.5 0 0 1 15 5.5V21" />
<path d="M15 10h2.5A1.5 1.5 0 0 1 19 11.5V21" />
<path d="M8 8h2M8 12h2M8 16h2M12 8h1M12 12h1M12 16h1" />
<path d="M4 21h16" />
</template>
<template v-else-if="name === 'user'">
<circle cx="12" cy="8" r="3.2" />
<path d="M5 19c.9-3.2 3.2-4.8 7-4.8s6.1 1.6 7 4.8" />
</template>
<template v-else-if="name === 'flag'">
<path d="M6 21V4" />
<path d="M6 4h10.5l-1.8 3.4L16.5 11H6" />
</template>
<template v-else-if="name === 'refresh'">
<path d="M20 12a8 8 0 1 1-2.2-5.5" />
<path d="M20 4v5h-5" />
</template>
<template v-else-if="name === 'x'">
<path d="M6 6 18 18M18 6 6 18" />
</template>
<template v-else-if="name === 'mic'">
<rect x="9" y="3.5" width="6" height="10" rx="3" />
<path d="M7 11.5a5 5 0 0 0 10 0" />
<path d="M12 16.5V20M9 20h6" />
</template>
<template v-else-if="name === 'trash'">
<path d="M5 7h14" />
<path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7" />
<path d="M7.5 7 8.3 19a1.5 1.5 0 0 0 1.5 1.4h4.4a1.5 1.5 0 0 0 1.5-1.4L16.5 7" />
</template>
<template v-else-if="name === 'stop'">
<rect x="7.5" y="7.5" width="9" height="9" rx="1.6" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'minus'">
<path d="M6 12h12" />
</template>
<template v-else-if="name === 'check-plain'">
<path d="m5.5 12.2 4.2 4.3 8.8-9" />
</template>
</svg>
</template>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { personInitials } from '@/lib/person-insights';
const props = withDefaults(
defineProps<{
name?: string | null;
src?: string | null;
size?: number;
}>(),
{
name: '',
src: '/brand/user-avatar.png',
size: 28,
},
);
const initials = computed(() => personInitials(props.name));
const failed = ref(false);
</script>
<template>
<span class="user-avatar" :style="{ width: `${size}px`, height: `${size}px` }" :title="name || undefined">
<img
v-if="src && !failed"
:src="src"
:alt="name || 'آواتار'"
@error="failed = true"
/>
<em v-else>{{ initials }}</em>
</span>
</template>
<style scoped>
.user-avatar {
display: inline-grid;
place-items: center;
border-radius: 999px;
overflow: hidden;
flex-shrink: 0;
background: linear-gradient(145deg, #5eead4, #0f766e);
box-shadow: 0 0 0 2px #fff, 0 0 0 3px #ccfbf1;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
em {
font-style: normal;
color: #fff;
font-weight: 800;
font-size: 0.62rem;
line-height: 1;
}
</style>
@@ -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>
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router';
import type { RouteLocationRaw } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
withDefaults(
defineProps<{
title?: string;
description?: string;
primaryLabel: string;
primaryTo: RouteLocationRaw;
secondaryLabel?: string;
secondaryTo?: RouteLocationRaw;
secondaryIcon?: 'calendar' | 'calendar-plus' | 'user-plus';
}>(),
{
title: 'هنوز اطلاعاتی ثبت نشده!',
description:
'برای شروع، اطلاعات اولیه مشتری را ثبت کنید یا اولین فعالیت خود را اضافه نمایید.',
secondaryIcon: 'calendar-plus',
},
);
</script>
<template>
<div class="empty-state">
<div class="art" aria-hidden="true">
<span class="spark s1">✦</span>
<span class="spark s2">✦</span>
<span class="spark s3">✦</span>
<svg viewBox="0 0 168 132" fill="none">
<path
d="M26 44c0-7.2 5.8-13 13-13h30l9 9h51c7.2 0 13 5.8 13 13v54c0 7.2-5.8 13-13 13H39c-7.2 0-13-5.8-13-13V44Z"
fill="#D9E6E8"
/>
<path
d="M26 50h116v47c0 7.2-5.8 13-13 13H39c-7.2 0-13-5.8-13-13V50Z"
fill="#E8F1F2"
/>
<rect x="54" y="34" width="60" height="74" rx="11" fill="#fff" />
<rect x="54" y="34" width="60" height="74" rx="11" stroke="#E2E8F0" />
<circle cx="84" cy="60" r="12" fill="#C5D4DA" />
<path
d="M66 96c2.2-9.2 8.4-14 18-14s15.8 4.8 18 14"
stroke="#C5D4DA"
stroke-width="9"
stroke-linecap="round"
/>
<circle cx="122" cy="100" r="17" fill="#14b8a6" />
<circle cx="122" cy="100" r="17" fill="#14b8a6" stroke="#fff" stroke-width="3" />
<path d="M122 92.5v15M114.5 100h15" stroke="#fff" stroke-width="2.8" stroke-linecap="round" />
</svg>
</div>
<h3>{{ title }}</h3>
<p>{{ description }}</p>
<div class="actions">
<RouterLink class="btn-primary" :to="primaryTo">
<span class="plus">+</span>
{{ primaryLabel }}
</RouterLink>
<RouterLink v-if="secondaryLabel && secondaryTo" class="btn-secondary" :to="secondaryTo">
<UiIcon :name="secondaryIcon" :size="18" />
{{ secondaryLabel }}
</RouterLink>
</div>
</div>
</template>
<style scoped>
.empty-state {
display: grid;
justify-items: center;
align-content: center;
text-align: center;
width: 100%;
padding: 1.4rem 0.8rem 0.6rem;
}
.art {
position: relative;
width: 168px;
margin-bottom: 1.05rem;
}
.art svg {
width: 168px;
height: auto;
display: block;
}
.spark {
position: absolute;
color: #5eead4;
font-size: 0.68rem;
line-height: 1;
}
.s1 {
top: 6px;
inset-inline-end: 8px;
}
.s2 {
top: 44px;
inset-inline-start: 2px;
font-size: 0.52rem;
color: #99f6e4;
}
.s3 {
bottom: 20px;
inset-inline-end: -2px;
font-size: 0.46rem;
color: #99f6e4;
}
h3 {
margin: 0;
font-size: 1.08rem;
font-weight: 800;
color: #1e2937;
}
p {
margin: 0.5rem 0 1.25rem;
max-width: 21.5rem;
color: #94a3b8;
font-size: 0.84rem;
line-height: 1.85;
}
.actions {
display: grid;
gap: 0.7rem;
width: min(100%, 304px);
}
.btn-primary,
.btn-secondary {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 48px;
border-radius: 16px;
font-weight: 700;
font-size: 0.9rem;
}
.btn-primary {
background: #14b8a6;
color: #fff;
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.26);
}
.plus {
font-size: 1.2rem;
line-height: 1;
font-weight: 600;
}
.btn-secondary {
background: #fff;
color: #0f766e;
border: 1px solid #e2e8f0;
}
@media (max-width: 599px) {
.empty-state {
padding: 1.2rem 0.2rem 0.4rem;
}
.art,
.art svg {
width: 152px;
}
h3 {
font-size: 1.02rem;
}
p {
font-size: 0.8rem;
margin-bottom: 1.15rem;
}
.btn-primary,
.btn-secondary {
min-height: 46px;
font-size: 0.86rem;
border-radius: 14px;
}
}
</style>
@@ -0,0 +1,215 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import type { RouteLocationRaw } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
export type ProfileMoreItem = {
key: string;
label: string;
to?: RouteLocationRaw;
danger?: boolean;
};
const open = defineModel<boolean>({ default: false });
defineProps<{ items: ProfileMoreItem[] }>();
const emit = defineEmits<{ select: [key: string] }>();
const triggerRef = ref<HTMLButtonElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const top = ref(0);
const left = ref(0);
function place() {
const trigger = triggerRef.value;
const panel = panelRef.value;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width = panel?.offsetWidth || 220;
const height = panel?.offsetHeight || 0;
const rtl = getComputedStyle(document.documentElement).direction === 'rtl';
let nextLeft = rtl ? rect.right - width : rect.left;
nextLeft = Math.min(Math.max(8, nextLeft), window.innerWidth - width - 8);
let nextTop = rect.bottom + 8;
if (nextTop + height > window.innerHeight - 8) {
nextTop = Math.max(8, rect.top - height - 8);
}
left.value = nextLeft;
top.value = nextTop;
}
function onWindowChange() {
if (open.value) place();
}
watch(open, async (value) => {
if (!value) {
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
return;
}
await nextTick();
place();
await nextTick();
place();
window.addEventListener('resize', onWindowChange);
window.addEventListener('scroll', onWindowChange, true);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
});
function toggle() {
open.value = !open.value;
}
function close() {
open.value = false;
}
function onSelect(key: string) {
emit('select', key);
nextTick(() => {
close();
});
}
</script>
<template>
<div class="wrap" @click.stop>
<button
ref="triggerRef"
class="icon-btn"
type="button"
aria-label="منوی بیشتر"
:aria-expanded="open"
@click="toggle"
>
<UiIcon name="dots-v" :size="18" />
</button>
<Teleport to="body">
<div v-if="open" class="layer">
<button class="backdrop" type="button" aria-label="بستن منو" @click="close" />
<ul
ref="panelRef"
class="panel"
role="menu"
:style="{ top: `${top}px`, left: `${left}px` }"
@click.stop
>
<li v-for="item in items" :key="item.key" role="none">
<RouterLink
v-if="item.to"
class="item"
:class="{ danger: item.danger }"
role="menuitem"
:to="item.to"
@click.stop="close"
>
{{ item.label }}
</RouterLink>
<button
v-else
class="item"
:class="{ danger: item.danger }"
type="button"
role="menuitem"
@click.stop.prevent="onSelect(item.key)"
>
{{ item.label }}
</button>
</li>
</ul>
</div>
</Teleport>
</div>
</template>
<style scoped>
.wrap {
position: relative;
flex-shrink: 0;
}
.icon-btn {
width: 38px;
height: 38px;
border: 0;
background: #fff;
border-radius: 12px;
display: grid;
place-items: center;
color: #334155;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.05);
cursor: pointer;
}
.layer {
position: fixed;
inset: 0;
z-index: 80;
}
.backdrop {
position: absolute;
inset: 0;
border: 0;
background: transparent;
cursor: default;
}
.panel {
position: absolute;
z-index: 1;
margin: 0;
padding: 0.4rem;
list-style: none;
width: 220px;
background: #fff;
border-radius: 14px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);
display: flex;
flex-direction: column;
gap: 2px;
}
.panel > li {
display: block;
width: 100%;
}
.item {
display: block;
width: 100%;
box-sizing: border-box;
margin: 0;
border: 0;
border-radius: 10px;
background: transparent;
color: #0f172a;
font: inherit;
font-weight: 600;
font-size: 0.9rem;
line-height: 1.6;
text-align: start;
text-decoration: none;
white-space: nowrap;
padding: 0.7rem 0.85rem;
cursor: pointer;
}
.item:hover {
background: #f0fdfa;
}
.item.danger {
color: #b91c1c;
}
.item.danger:hover {
background: #fef2f2;
}
</style>