Add activity detail, Jalali pickers, and customer/user profiles.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user