Add activity detail, Jalali pickers, and customer/user profiles.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,3 +15,4 @@ docs/~$*
|
||||
~$*
|
||||
.idea/
|
||||
.vscode/
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActivityPriority" AS ENUM ('low', 'medium', 'high');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Activity" ADD COLUMN "priority" "ActivityPriority" NOT NULL DEFAULT 'medium';
|
||||
ALTER TABLE "Activity" ADD COLUMN "createdById" TEXT;
|
||||
|
||||
UPDATE "Activity" SET "createdById" = "assigneeId" WHERE "createdById" IS NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Activity_organizationId_createdById_idx" ON "Activity"("organizationId", "createdById");
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ActivityPriority" ADD VALUE 'very_low';
|
||||
ALTER TYPE "ActivityPriority" ADD VALUE 'very_high';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Activity" ADD COLUMN "durationMinutes" INTEGER;
|
||||
ALTER TABLE "Activity" ADD COLUMN "reminderAt" TIMESTAMP(3);
|
||||
@@ -33,6 +33,14 @@ enum ActivityStatus {
|
||||
canceled
|
||||
}
|
||||
|
||||
enum ActivityPriority {
|
||||
low
|
||||
medium
|
||||
high
|
||||
very_low
|
||||
very_high
|
||||
}
|
||||
|
||||
enum CustomFieldEntity {
|
||||
person
|
||||
company
|
||||
@@ -165,6 +173,7 @@ model User {
|
||||
ownedCompanies Company[] @relation("CompanyOwner")
|
||||
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
|
||||
activities Activity[] @relation("ActivityAssignee")
|
||||
createdActivities Activity[] @relation("ActivityCreator")
|
||||
notes Note[] @relation("NoteAuthor")
|
||||
noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader")
|
||||
notifications Notification[]
|
||||
@@ -333,9 +342,12 @@ model Activity {
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
type ActivityType @default(task)
|
||||
status ActivityStatus @default(open)
|
||||
priority ActivityPriority @default(medium)
|
||||
title String
|
||||
description String?
|
||||
dueAt DateTime?
|
||||
durationMinutes Int?
|
||||
reminderAt DateTime?
|
||||
completedAt DateTime?
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
@@ -345,11 +357,14 @@ model Activity {
|
||||
deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
|
||||
assigneeId String?
|
||||
assignee User? @relation("ActivityAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
|
||||
createdById String?
|
||||
createdBy User? @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, status, dueAt])
|
||||
@@index([organizationId, assigneeId])
|
||||
@@index([organizationId, createdById])
|
||||
@@index([personId])
|
||||
@@index([companyId])
|
||||
@@index([dealId])
|
||||
|
||||
@@ -22,9 +22,12 @@ type ActivityRow = {
|
||||
id: string;
|
||||
type: ActivityDto['type'];
|
||||
status: ActivityDto['status'];
|
||||
priority: ActivityDto['priority'];
|
||||
title: string;
|
||||
description: string | null;
|
||||
dueAt: Date | null;
|
||||
durationMinutes: number | null;
|
||||
reminderAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -32,6 +35,7 @@ type ActivityRow = {
|
||||
company: { id: string; name: string } | null;
|
||||
deal: { id: string; title: string } | null;
|
||||
assignee: { id: string; fullName: string } | null;
|
||||
createdBy: { id: string; fullName: string } | null;
|
||||
};
|
||||
|
||||
const include = {
|
||||
@@ -39,6 +43,7 @@ const include = {
|
||||
company: { select: { id: true, name: true } },
|
||||
deal: { select: { id: true, title: true } },
|
||||
assignee: { select: { id: true, fullName: true } },
|
||||
createdBy: { select: { id: true, fullName: true } },
|
||||
} satisfies Prisma.ActivityInclude;
|
||||
|
||||
@Injectable()
|
||||
@@ -173,12 +178,16 @@ export class ActivitiesService {
|
||||
organizationId,
|
||||
title: dto.title.trim(),
|
||||
type: dto.type ?? 'task',
|
||||
priority: dto.priority ?? 'medium',
|
||||
description: emptyToNull(dto.description),
|
||||
dueAt: dto.dueAt ? new Date(dto.dueAt) : null,
|
||||
durationMinutes: dto.durationMinutes ?? null,
|
||||
reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : null,
|
||||
personId: emptyToNull(dto.personId ?? null),
|
||||
companyId: emptyToNull(dto.companyId ?? null),
|
||||
dealId: emptyToNull(dto.dealId ?? null),
|
||||
assigneeId,
|
||||
createdById: userId,
|
||||
},
|
||||
include,
|
||||
});
|
||||
@@ -211,8 +220,11 @@ export class ActivitiesService {
|
||||
const data: Prisma.ActivityUncheckedUpdateInput = {};
|
||||
if (dto.title !== undefined) data.title = dto.title.trim();
|
||||
if (dto.type !== undefined) data.type = dto.type;
|
||||
if (dto.priority !== undefined) data.priority = dto.priority;
|
||||
if ('description' in dto) data.description = emptyToNull(dto.description);
|
||||
if ('dueAt' in dto) data.dueAt = dto.dueAt ? new Date(dto.dueAt) : null;
|
||||
if ('durationMinutes' in dto) data.durationMinutes = dto.durationMinutes ?? null;
|
||||
if ('reminderAt' in dto) data.reminderAt = dto.reminderAt ? new Date(dto.reminderAt) : null;
|
||||
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
|
||||
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
||||
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
|
||||
@@ -394,9 +406,12 @@ export class ActivitiesService {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
dueAt: row.dueAt?.toISOString() ?? null,
|
||||
durationMinutes: row.durationMinutes,
|
||||
reminderAt: row.reminderAt?.toISOString() ?? null,
|
||||
completedAt: row.completedAt?.toISOString() ?? null,
|
||||
person: row.person
|
||||
? {
|
||||
@@ -407,6 +422,7 @@ export class ActivitiesService {
|
||||
company: row.company,
|
||||
deal: row.deal,
|
||||
assignee: row.assignee,
|
||||
createdBy: row.createdBy,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
|
||||
const ACTIVITY_TYPES = ['call', 'meeting', 'task', 'note'] as const;
|
||||
const ACTIVITY_STATUSES = ['open', 'done', 'canceled'] as const;
|
||||
const ACTIVITY_PRIORITIES = ['very_low', 'low', 'medium', 'high', 'very_high'] as const;
|
||||
const ACTIVITY_FILTERS = ['today', 'overdue', 'upcoming', 'done', 'all'] as const;
|
||||
|
||||
export class ListActivitiesQueryDto {
|
||||
@@ -71,6 +72,10 @@ export class CreateActivityDto {
|
||||
@IsIn(ACTIVITY_TYPES)
|
||||
type?: (typeof ACTIVITY_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_PRIORITIES)
|
||||
priority?: (typeof ACTIVITY_PRIORITIES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
@@ -81,6 +86,18 @@ export class CreateActivityDto {
|
||||
@IsDateString()
|
||||
dueAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(24 * 60)
|
||||
durationMinutes?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsDateString()
|
||||
reminderAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@@ -117,6 +134,10 @@ export class UpdateActivityDto {
|
||||
@IsIn(ACTIVITY_STATUSES)
|
||||
status?: (typeof ACTIVITY_STATUSES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(ACTIVITY_PRIORITIES)
|
||||
priority?: (typeof ACTIVITY_PRIORITIES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
@@ -127,6 +148,18 @@ export class UpdateActivityDto {
|
||||
@IsDateString()
|
||||
dueAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(24 * 60)
|
||||
durationMinutes?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsDateString()
|
||||
reminderAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
|
||||
@@ -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>
|
||||
@@ -24,14 +24,6 @@ const showOwnRecordsBanner = computed(
|
||||
const roleLabel = computed(() =>
|
||||
auth.user?.role ? USER_ROLE_LABELS[auth.user.role] : '',
|
||||
);
|
||||
const userInitials = computed(() => {
|
||||
const parts = auth.user?.fullName?.trim().split(/\s+/) ?? [];
|
||||
if (!parts.length) return 'م';
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0])
|
||||
.join('');
|
||||
});
|
||||
const todayLabel = computed(() => formatJalaliShort(new Date()));
|
||||
|
||||
const salesOpen = ref(false);
|
||||
@@ -344,8 +336,14 @@ onUnmounted(() => {
|
||||
</p>
|
||||
|
||||
<div class="sidebar-foot">
|
||||
<div class="profile-card">
|
||||
<span class="avatar">{{ userInitials }}</span>
|
||||
<RouterLink
|
||||
class="profile-card"
|
||||
:to="{ name: 'profile' }"
|
||||
@click="closeMenu"
|
||||
>
|
||||
<span class="avatar">
|
||||
<img src="/brand/user-avatar.png" :alt="auth.user?.fullName || 'کاربر'" width="48" height="48" />
|
||||
</span>
|
||||
<div class="profile-meta">
|
||||
<strong>{{ auth.user?.fullName }}</strong>
|
||||
<span>{{ roleLabel || 'کاربر' }}</span>
|
||||
@@ -354,14 +352,17 @@ onUnmounted(() => {
|
||||
آنلاین
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<div class="promo-card">
|
||||
<span class="promo-icons" aria-hidden="true">
|
||||
<UiIcon name="send" :size="16" />
|
||||
<UiIcon name="sparkle" :size="14" />
|
||||
<UiIcon name="send" :size="20" />
|
||||
<UiIcon name="sparkle" :size="18" />
|
||||
</span>
|
||||
<p>با منتو فروشات را چند برابر کن</p>
|
||||
<p>
|
||||
<span>با منتو فروشات را</span>
|
||||
<span>چند برابر کن</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="logout-link" type="button" @click="onLogout">خروج از حساب</button>
|
||||
@@ -689,29 +690,46 @@ onUnmounted(() => {
|
||||
|
||||
.profile-card {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 0.9rem;
|
||||
border-radius: 18px;
|
||||
background: #f8fafc;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.profile-card:hover {
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.profile-card.router-link-active {
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(145deg, #99f6e4, #5eead4);
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.profile-meta strong {
|
||||
@@ -729,6 +747,7 @@ onUnmounted(() => {
|
||||
.online-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.35rem;
|
||||
color: #64748b !important;
|
||||
font-size: 0.74rem !important;
|
||||
@@ -745,7 +764,7 @@ onUnmounted(() => {
|
||||
.promo-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
gap: 0.75rem;
|
||||
padding: 1.05rem 1.05rem;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(105deg, #c4e84a 0%, #2dd4bf 42%, #1d4ed8 100%);
|
||||
@@ -755,17 +774,27 @@ onUnmounted(() => {
|
||||
|
||||
.promo-icons {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.promo-card p {
|
||||
margin: 0;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.5;
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.promo-card p span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.logout-link {
|
||||
|
||||
@@ -176,13 +176,97 @@ export function formatJalaliShort(d: Date): string {
|
||||
return `${weekdayName(d)} ${toFaDigits(jd)} ${jalaliMonthName(jm)}`;
|
||||
}
|
||||
|
||||
/** مثل: ۸ شهریور ۱۴۰۵ */
|
||||
export function formatJalaliDay(d: Date): string {
|
||||
const { jy, jm, jd } = dateToJalali(d);
|
||||
return `${toFaDigits(jd)} ${jalaliMonthName(jm)} ${toFaDigits(jy)}`;
|
||||
}
|
||||
|
||||
export function toFaDigits(value: number | string): string {
|
||||
return String(value).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]!);
|
||||
}
|
||||
|
||||
export function startOfLocalDay(d = new Date()): Date {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
export function formatClockFa(d: Date): string {
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${toFaDigits(hh)}:${toFaDigits(mm)}`;
|
||||
}
|
||||
|
||||
/** مثل: ۲۴ مرداد ۱۱:۲۰ */
|
||||
export function formatJalaliDayClock(d: Date): string {
|
||||
const { jm, jd } = dateToJalali(d);
|
||||
return `${toFaDigits(jd)} ${jalaliMonthName(jm)} ${formatClockFa(d)}`;
|
||||
}
|
||||
|
||||
export function calendarDaysBetween(from: Date, to: Date): number {
|
||||
return Math.round(
|
||||
(startOfLocalDay(to).getTime() - startOfLocalDay(from).getTime()) / 86_400_000,
|
||||
);
|
||||
}
|
||||
|
||||
/** آخرین ارتباط: امروز ۱۶:۳۰ / ۶ روز پیش */
|
||||
export function formatRelativeContact(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
const days = calendarDaysBetween(d, new Date());
|
||||
if (days <= 0) return `امروز ${formatClockFa(d)}`;
|
||||
if (days === 1) return 'دیروز';
|
||||
return `${toFaDigits(days)} روز پیش`;
|
||||
}
|
||||
|
||||
/** فعالیت بعدی: امروز ۱۶:۳۰ / فردا ۱۰:۰۰ / ۲۴ مرداد ۱۱:۲۰ */
|
||||
export function formatNextSlot(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
const days = calendarDaysBetween(new Date(), d);
|
||||
if (days === 0) return `امروز ${formatClockFa(d)}`;
|
||||
if (days === 1) return `فردا ${formatClockFa(d)}`;
|
||||
if (days === -1) return `دیروز ${formatClockFa(d)}`;
|
||||
return formatJalaliDayClock(d);
|
||||
}
|
||||
|
||||
export function gregorianDayKey(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function parseLocalInput(value: string | null | undefined): Date | null {
|
||||
if (!value) return null;
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?/.exec(value);
|
||||
if (match) {
|
||||
return new Date(
|
||||
Number(match[1]),
|
||||
Number(match[2]) - 1,
|
||||
Number(match[3]),
|
||||
match[4] ? Number(match[4]) : 0,
|
||||
match[5] ? Number(match[5]) : 0,
|
||||
);
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
export function formatLocalDateInput(d: Date): string {
|
||||
return gregorianDayKey(d);
|
||||
}
|
||||
|
||||
export function formatLocalDateTimeInput(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${gregorianDayKey(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** مثل: ۱۴۰۵/۰۶/۱۲ */
|
||||
export function formatJalaliNumeric(d: Date): string {
|
||||
const { jy, jm, jd } = dateToJalali(d);
|
||||
return `${toFaDigits(jy)}/${toFaDigits(String(jm).padStart(2, '0'))}/${toFaDigits(String(jd).padStart(2, '0'))}`;
|
||||
}
|
||||
|
||||
@@ -22,3 +22,20 @@ export function formatMoney(amount: number, currency = 'IRR'): string {
|
||||
return `${amount} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** مثل ماکاپ: ۲۴۰ میلیون ریال */
|
||||
export function formatCompactMoney(amount: number, currency = 'IRR'): string {
|
||||
const suffix = currencySuffix(currency);
|
||||
const abs = Math.abs(amount);
|
||||
if (abs >= 1_000_000_000) {
|
||||
const n = amount / 1_000_000_000;
|
||||
const rounded = Math.abs(n - Math.round(n)) < 0.05 ? Math.round(n) : Math.round(n * 10) / 10;
|
||||
return `${new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 1 }).format(rounded)} میلیارد ${suffix}`;
|
||||
}
|
||||
if (abs >= 1_000_000) {
|
||||
const n = amount / 1_000_000;
|
||||
const rounded = Math.abs(n - Math.round(n)) < 0.05 ? Math.round(n) : Math.round(n * 10) / 10;
|
||||
return `${new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 1 }).format(rounded)} میلیون ${suffix}`;
|
||||
}
|
||||
return formatMoney(amount, currency);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { MemoryItemDto, NoteDto, PersonDto } from '@mento/shared';
|
||||
|
||||
export type ProbabilityBand = 'بالا' | 'متوسط' | 'پایین';
|
||||
|
||||
export function personInitials(fullName: string | null | undefined): string {
|
||||
const parts = fullName?.trim().split(/\s+/) ?? [];
|
||||
if (!parts.length) return 'م';
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0])
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function isVipPerson(person: PersonDto | null): boolean {
|
||||
if (!person) return false;
|
||||
const tier = person.loyaltyTier?.name ?? '';
|
||||
if (/vip|طلا|طلایی|gold|پلاتین|platinum/i.test(tier)) return true;
|
||||
if ((person.loyaltyPoints ?? 0) >= 800) return true;
|
||||
const fields = person.customFields ?? {};
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (/vip/i.test(key) && (value === true || value === 'true' || value === 1 || value === '1')) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value === 'string' && /^vip$/i.test(value.trim())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function purchaseProbability(input: {
|
||||
daysSinceContact: number | null;
|
||||
openDealCount: number;
|
||||
quoteCount: number;
|
||||
hasUpcoming: boolean;
|
||||
noteCount: number;
|
||||
}): { score: number; band: ProbabilityBand } {
|
||||
let score = 38;
|
||||
if (input.openDealCount > 0) score += 16 + Math.min(8, input.openDealCount * 2);
|
||||
if (input.quoteCount > 0) score += 8;
|
||||
if (input.noteCount > 0) score += 4;
|
||||
if (input.hasUpcoming) score += 6;
|
||||
if (input.daysSinceContact != null) {
|
||||
if (input.daysSinceContact <= 2) score += 24;
|
||||
else if (input.daysSinceContact <= 7) score += 18;
|
||||
else if (input.daysSinceContact <= 14) score += 8;
|
||||
else if (input.daysSinceContact <= 30) score += 2;
|
||||
else score -= 10;
|
||||
} else {
|
||||
score -= 6;
|
||||
}
|
||||
score = Math.max(8, Math.min(95, Math.round(score)));
|
||||
const band: ProbabilityBand = score >= 70 ? 'بالا' : score >= 45 ? 'متوسط' : 'پایین';
|
||||
return { score, band };
|
||||
}
|
||||
|
||||
export function buildSmartSummary(input: {
|
||||
memories: MemoryItemDto[];
|
||||
notes: NoteDto[];
|
||||
lastQuoteLabel: string | null;
|
||||
daysSinceCall: number | null;
|
||||
probabilityBand: ProbabilityBand;
|
||||
}): { text: string; cta: string; ctaKind: 'call' | 'activity' } {
|
||||
const parts: string[] = [];
|
||||
const memoryBits = input.memories
|
||||
.map((m) => m.content.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 2);
|
||||
if (memoryBits.length) {
|
||||
parts.push(memoryBits.join(' '));
|
||||
} else {
|
||||
const note = input.notes.find((n) => n.body.trim());
|
||||
if (note?.body) {
|
||||
const snippet = note.body.replace(/\s+/g, ' ').trim();
|
||||
parts.push(snippet.length > 90 ? `${snippet.slice(0, 90)}…` : snippet);
|
||||
}
|
||||
}
|
||||
if (input.lastQuoteLabel) {
|
||||
parts.push(`آخرین پیشنهاد ${input.lastQuoteLabel} بوده.`);
|
||||
}
|
||||
if (input.daysSinceCall != null) {
|
||||
if (input.daysSinceCall <= 0) parts.push('امروز با مشتری تماس گرفته شده.');
|
||||
else parts.push(`${input.daysSinceCall} روز از آخرین تماس گذشته`);
|
||||
}
|
||||
parts.push(`و احتمال خرید ${input.probabilityBand} است.`);
|
||||
|
||||
const hasSignal = memoryBits.length > 0 || input.notes.length > 0 || input.lastQuoteLabel;
|
||||
const text = hasSignal
|
||||
? parts.join(' ').replace(/\s+/g, ' ').trim()
|
||||
: 'هنوز خلاصهٔ پایداری از این مشتری ثبت نشده. با یادداشت یا حافظهٔ منتو، پیشنهاد هوشمند اینجا ظاهر میشود.';
|
||||
|
||||
let cta = 'پیشنهاد منتو: امروز با مشتری تماس بگیر';
|
||||
let ctaKind: 'call' | 'activity' = 'call';
|
||||
if (input.daysSinceCall != null && input.daysSinceCall <= 1) {
|
||||
cta = 'پیشنهاد منتو: پیگیری بعدی را ثبت کن';
|
||||
ctaKind = 'activity';
|
||||
}
|
||||
return { text, cta, ctaKind };
|
||||
}
|
||||
@@ -38,6 +38,11 @@ export const router = createRouter({
|
||||
name: 'home',
|
||||
component: () => import('@/views/HomeView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'profile',
|
||||
component: () => import('@/views/ProfileView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
name: 'reports',
|
||||
@@ -78,10 +83,15 @@ export const router = createRouter({
|
||||
name: 'activity-new',
|
||||
component: () => import('@/views/ActivityFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'activities/:id/edit',
|
||||
name: 'activity-edit',
|
||||
component: () => import('@/views/ActivityFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'activities/:id',
|
||||
name: 'activity-detail',
|
||||
component: () => import('@/views/ActivityFormView.vue'),
|
||||
component: () => import('@/views/ActivityDetailView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'quotes',
|
||||
@@ -205,10 +215,15 @@ export const router = createRouter({
|
||||
name: 'person-new',
|
||||
component: () => import('@/views/PersonFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'contacts/people/:id/edit',
|
||||
name: 'person-edit',
|
||||
component: () => import('@/views/PersonFormView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'contacts/people/:id',
|
||||
name: 'person-detail',
|
||||
component: () => import('@/views/PersonFormView.vue'),
|
||||
component: () => import('@/views/PersonProfileView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'contacts/companies/new',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,616 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
calendarDaysBetween,
|
||||
formatClockFa,
|
||||
formatJalaliDay,
|
||||
} from '@/lib/jalali';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
|
||||
import ActivityPostponeSheet from '@/components/activities/ActivityPostponeSheet.vue';
|
||||
import type { ActivityDto, ActivityPriority, ActivityType } from '@mento/shared';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
||||
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const activity = ref<ActivityDto | null>(null);
|
||||
const moreOpen = ref(false);
|
||||
const commentOpen = ref(false);
|
||||
const postponeOpen = ref(false);
|
||||
const noteOpen = ref(true);
|
||||
|
||||
const typeLabel: Record<ActivityType, string> = {
|
||||
call: 'تماس',
|
||||
meeting: 'جلسه',
|
||||
task: 'وظیفه',
|
||||
note: 'پیام',
|
||||
};
|
||||
|
||||
const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
|
||||
call: 'phone',
|
||||
meeting: 'users',
|
||||
task: 'check',
|
||||
note: 'chat',
|
||||
};
|
||||
|
||||
const priorityLabel: Record<ActivityPriority, string> = {
|
||||
very_low: 'خیلی کم',
|
||||
low: 'کم',
|
||||
medium: 'متوسط',
|
||||
high: 'زیاد',
|
||||
very_high: 'خیلی زیاد',
|
||||
};
|
||||
|
||||
const subtitle = computed(() => {
|
||||
const a = activity.value;
|
||||
if (!a) return '';
|
||||
return a.deal?.title || a.company?.name || a.person?.fullName || '';
|
||||
});
|
||||
|
||||
const dueLabel = computed(() => {
|
||||
const due = activity.value?.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 createdLabel = computed(() => {
|
||||
const raw = activity.value?.createdAt;
|
||||
if (!raw) return '—';
|
||||
const d = new Date(raw);
|
||||
return `${formatJalaliDay(d)} - ${formatClockFa(d)}`;
|
||||
});
|
||||
|
||||
const isDone = computed(() => activity.value?.status === 'done');
|
||||
const statusText = computed(() => {
|
||||
if (!activity.value) return '—';
|
||||
if (activity.value.status === 'done') return 'انجام شد';
|
||||
if (activity.value.status === 'canceled') return 'لغو شده';
|
||||
return 'انجام نشده';
|
||||
});
|
||||
|
||||
async function load() {
|
||||
if (!id.value) return;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
moreOpen.value = false;
|
||||
try {
|
||||
const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
|
||||
activity.value = data;
|
||||
} catch {
|
||||
error.value = 'بارگذاری جزئیات فعالیت ناموفق بود.';
|
||||
activity.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function complete() {
|
||||
if (!id.value || isDone.value) return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.patch(`/activities/${id.value}/complete`);
|
||||
await router.push({ name: 'activities' });
|
||||
} catch {
|
||||
error.value = 'ثبت انجامشدن ناموفق بود.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function postponeTo(iso: string) {
|
||||
if (!id.value || !activity.value || isDone.value) return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.patch(`/activities/${id.value}`, { dueAt: iso });
|
||||
postponeOpen.value = false;
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'تعویق فعالیت ناموفق بود.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComment(text: string) {
|
||||
const body = text.trim();
|
||||
if (!id.value || !activity.value || !body) return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const prev = activity.value.description?.trim();
|
||||
const next = prev ? `${prev}\n${body}` : body;
|
||||
await api.patch(`/activities/${id.value}`, { description: next });
|
||||
commentOpen.value = false;
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'ثبت نظر ناموفق بود.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
moreOpen.value = false;
|
||||
if (!id.value || !confirm('این فعالیت حذف شود؟')) return;
|
||||
try {
|
||||
await api.delete(`/activities/${id.value}`);
|
||||
await router.push({ name: 'activities' });
|
||||
} catch {
|
||||
error.value = 'حذف ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
watch(id, () => {
|
||||
commentOpen.value = false;
|
||||
postponeOpen.value = false;
|
||||
void load();
|
||||
});
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page" @click="moreOpen = false">
|
||||
<header class="nav">
|
||||
<RouterLink class="icon-btn back" :to="{ name: 'activities' }" aria-label="بازگشت">
|
||||
<UiIcon name="chevron" :size="18" />
|
||||
</RouterLink>
|
||||
<span class="brand">mento</span>
|
||||
<div class="more" @click.stop>
|
||||
<button class="icon-btn" type="button" aria-label="منوی بیشتر" @click="moreOpen = !moreOpen">
|
||||
<UiIcon name="dots-v" :size="18" />
|
||||
</button>
|
||||
<div v-if="moreOpen && activity" class="menu">
|
||||
<RouterLink :to="{ name: 'activity-edit', params: { id } }">ویرایش</RouterLink>
|
||||
<button type="button" class="danger" @click="remove">حذف</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h1>جزئیات فعالیت</h1>
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<p v-else-if="error && !activity" class="error">{{ error }}</p>
|
||||
|
||||
<template v-else-if="activity">
|
||||
<article class="card">
|
||||
<header class="hero">
|
||||
<div>
|
||||
<h2>
|
||||
{{ activity.title }}
|
||||
<UiIcon name="star" :size="16" class="star" />
|
||||
</h2>
|
||||
<p v-if="subtitle" class="sub">
|
||||
<UiIcon name="building" :size="14" />
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="type-ico" :class="activity.type">
|
||||
<UiIcon :name="typeIcon[activity.type]" :size="22" />
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<dl>
|
||||
<div>
|
||||
<dt>
|
||||
<UiIcon :name="typeIcon[activity.type]" :size="15" />
|
||||
نوع فعالیت
|
||||
</dt>
|
||||
<dd>{{ typeLabel[activity.type] }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
<UiIcon name="calendar" :size="15" />
|
||||
تاریخ و زمان
|
||||
</dt>
|
||||
<dd>{{ dueLabel }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
<UiIcon name="user" :size="15" />
|
||||
مسئول
|
||||
</dt>
|
||||
<dd class="person">
|
||||
<UserAvatar v-if="activity.assignee" :name="activity.assignee.fullName" :size="28" />
|
||||
{{ activity.assignee?.fullName || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
<UiIcon name="flag" :size="15" class="flag" :class="activity.priority || 'medium'" />
|
||||
اولویت
|
||||
</dt>
|
||||
<dd class="priority" :class="activity.priority || 'medium'">
|
||||
{{ priorityLabel[activity.priority || 'medium'] }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>وضعیت</dt>
|
||||
<dd>
|
||||
<span class="status" :class="{ done: isDone }">{{ statusText }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
<UiIcon name="user" :size="15" />
|
||||
ایجاد شده توسط
|
||||
</dt>
|
||||
<dd class="person">
|
||||
<UserAvatar v-if="activity.createdBy" :name="activity.createdBy.fullName" :size="28" />
|
||||
{{ activity.createdBy?.fullName || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
<UiIcon name="calendar" :size="15" />
|
||||
تاریخ ایجاد
|
||||
</dt>
|
||||
<dd>{{ createdLabel }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article class="card note">
|
||||
<button class="note-head" type="button" @click="noteOpen = !noteOpen">
|
||||
<strong>
|
||||
<UiIcon name="document" :size="16" />
|
||||
یادداشت
|
||||
</strong>
|
||||
<UiIcon name="chevron" :size="16" :class="{ open: noteOpen }" />
|
||||
</button>
|
||||
<p v-show="noteOpen" :class="{ empty: !activity.description?.trim() }">
|
||||
{{ activity.description?.trim() || 'یادداشتی ثبت نشده.' }}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<ActivityCommentSheet
|
||||
v-if="commentOpen"
|
||||
:activity="activity"
|
||||
:saving="saving"
|
||||
@close="commentOpen = false"
|
||||
@submit="submitComment"
|
||||
/>
|
||||
<ActivityPostponeSheet
|
||||
v-if="postponeOpen"
|
||||
:due-at="activity.dueAt"
|
||||
:saving="saving"
|
||||
@close="postponeOpen = false"
|
||||
@submit="postponeTo"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<footer v-if="activity && !commentOpen && !postponeOpen" class="dock">
|
||||
<button class="act primary" type="button" :disabled="saving || isDone" @click="complete">
|
||||
<UiIcon name="check" :size="16" />
|
||||
انجام شد
|
||||
</button>
|
||||
<button class="act ghost" type="button" :disabled="saving || isDone" @click="postponeOpen = true">
|
||||
<UiIcon name="refresh" :size="16" />
|
||||
تعویق
|
||||
</button>
|
||||
<button class="act ghost" type="button" :disabled="saving" @click="commentOpen = true">
|
||||
<UiIcon name="chat" :size="16" />
|
||||
ثبت نظر
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 640px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
padding-bottom: 5.8rem;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 800;
|
||||
color: #0f766e;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.back :deep(.ico) {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.more {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
inset-inline-end: 0;
|
||||
min-width: 10rem;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
||||
padding: 0.35rem;
|
||||
display: grid;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
.menu a,
|
||||
.menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.62rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.menu .danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0.1rem 0 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.muted,
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
|
||||
padding: 1rem 1.05rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
align-items: flex-start;
|
||||
padding-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.hero h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.star {
|
||||
color: #cbd5e1;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 0.4rem 0 0;
|
||||
color: #64748b;
|
||||
font-size: 0.84rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.type-ico {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.type-ico.call {
|
||||
background: #ecfdf5;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.type-ico.meeting {
|
||||
background: #f5f3ff;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.type-ico.task {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.type-ico.note {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dl > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
padding: 0.78rem 0;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
dt,
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: #64748b;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
font-weight: 700;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.flag.low,
|
||||
.priority.low {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.flag.medium,
|
||||
.priority.medium {
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.flag.high,
|
||||
.priority.high {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
border-radius: 999px;
|
||||
padding: 0.22rem 0.65rem;
|
||||
background: #ecfdf5;
|
||||
color: #0f766e;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.status.done {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.note-head {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: #0f766e;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.note-head :deep(.ico:last-child) {
|
||||
transform: rotate(90deg);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.note-head :deep(.ico.open) {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.note p {
|
||||
margin: 0.75rem 0 0;
|
||||
color: #334155;
|
||||
line-height: 1.9;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.note p.empty {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.dock {
|
||||
position: fixed;
|
||||
inset-inline: 0.85rem;
|
||||
bottom: calc(0.55rem + env(safe-area-inset-bottom));
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.act {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
min-height: 46px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #99f6e4;
|
||||
background: #fff;
|
||||
color: #0f766e;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.act.primary {
|
||||
background: #14b8a6;
|
||||
border-color: #14b8a6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.act:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.dock {
|
||||
position: sticky;
|
||||
inset-inline: auto;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import EntityTimeline from '@/components/EntityTimeline.vue';
|
||||
import { formatMoney } from '@/lib/money';
|
||||
import type {
|
||||
@@ -167,6 +168,14 @@ async function load() {
|
||||
} else {
|
||||
const firstOpen = stages.value.find((s) => !s.isWon && !s.isLost);
|
||||
stageId.value = firstOpen?.id ?? stages.value[0]?.id ?? '';
|
||||
if (typeof route.query.personId === 'string' && route.query.personId) {
|
||||
personId.value = route.query.personId;
|
||||
const person = people.value.find((p) => p.id === personId.value);
|
||||
if (person?.company?.id) companyId.value = person.company.id;
|
||||
}
|
||||
if (typeof route.query.companyId === 'string' && route.query.companyId) {
|
||||
companyId.value = route.query.companyId;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
error.value = 'بارگذاری فرم معامله ناموفق بود.';
|
||||
@@ -310,7 +319,7 @@ onMounted(load);
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="expectedCloseAt">تاریخ بستهشدن مورد انتظار</label>
|
||||
<input id="expectedCloseAt" v-model="expectedCloseAt" type="date" />
|
||||
<JalaliDateInput id="expectedCloseAt" v-model="expectedCloseAt" />
|
||||
</div>
|
||||
<div class="field wide">
|
||||
<label for="tags">برچسبها (با کاما جدا کنید)</label>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatMoney as formatMoneyLib } from '@/lib/money';
|
||||
import { formatLocalDateInput } from '@/lib/jalali';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import type {
|
||||
CompanyDto,
|
||||
CreateInvoiceRequest,
|
||||
@@ -66,7 +68,7 @@ const payAmount = ref('');
|
||||
const payMethod = ref<PaymentMethod>('transfer');
|
||||
const payNotes = ref('');
|
||||
const payInstallmentId = ref('');
|
||||
const payDate = ref(new Date().toISOString().slice(0, 10));
|
||||
const payDate = ref(formatLocalDateInput(new Date()));
|
||||
|
||||
const statusLabel: Record<InvoiceStatus, string> = {
|
||||
draft: 'پیشنویس',
|
||||
@@ -137,7 +139,7 @@ function splitInstallments(count: number) {
|
||||
d.setMonth(d.getMonth() + i + 1);
|
||||
return {
|
||||
key: `split-${i}-${Date.now()}`,
|
||||
dueDate: d.toISOString().slice(0, 10),
|
||||
dueDate: formatLocalDateInput(d),
|
||||
amount: String(base + (i === count - 1 ? remainder : 0)),
|
||||
};
|
||||
});
|
||||
@@ -414,7 +416,7 @@ onMounted(load);
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>سررسید</label>
|
||||
<input v-model="dueDate" type="date" />
|
||||
<JalaliDateInput v-model="dueDate" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>ارز</label>
|
||||
@@ -512,7 +514,7 @@ onMounted(load);
|
||||
:key="item.key"
|
||||
class="installment-row"
|
||||
>
|
||||
<input v-model="item.dueDate" type="date" />
|
||||
<JalaliDateInput v-model="item.dueDate" />
|
||||
<input
|
||||
v-model="item.amount"
|
||||
type="number"
|
||||
@@ -561,7 +563,7 @@ onMounted(load);
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
<input v-model="payDate" type="date" />
|
||||
<JalaliDateInput v-model="payDate" />
|
||||
<select v-model="payInstallmentId">
|
||||
<option value="">بدون اتصال به قسط</option>
|
||||
<option
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import NoteAiSuggestionPanel from '@/components/NoteAiSuggestionPanel.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import type {
|
||||
DealDto,
|
||||
NoteAttachmentDto,
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
} from '@mento/shared';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
@@ -72,6 +74,13 @@ async function load() {
|
||||
notes.value = notesRes.data.items;
|
||||
people.value = peopleRes.data.items;
|
||||
deals.value = boardRes.data.stages.flatMap((s) => s.deals);
|
||||
if (typeof route.query.personId === 'string' && route.query.personId) {
|
||||
draftPersonId.value = route.query.personId;
|
||||
showExtras.value = true;
|
||||
}
|
||||
if (typeof route.query.q === 'string' && route.query.q && !q.value) {
|
||||
q.value = route.query.q;
|
||||
}
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && !e.response) {
|
||||
error.value = 'اتصال به API برقرار نشد.';
|
||||
@@ -372,7 +381,7 @@ onMounted(load);
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>یادآوری</label>
|
||||
<input v-model="draftReminder" type="datetime-local" />
|
||||
<JalaliDateInput v-model="draftReminder" mode="datetime" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -165,7 +165,7 @@ async function submit() {
|
||||
|
||||
await api.patch(`/people/${id.value}`, payload());
|
||||
|
||||
await router.push({ name: 'contacts', query: { tab: undefined } });
|
||||
await router.push({ name: 'person-detail', params: { id: id.value } });
|
||||
|
||||
}
|
||||
|
||||
@@ -241,7 +241,10 @@ onMounted(load);
|
||||
|
||||
</div>
|
||||
|
||||
<RouterLink class="btn btn-ghost" :to="{ name: 'contacts' }">بازگشت</RouterLink>
|
||||
<RouterLink
|
||||
class="btn btn-ghost"
|
||||
:to="isNew ? { name: 'contacts' } : { name: 'person-detail', params: { id } }"
|
||||
>بازگشت</RouterLink>
|
||||
|
||||
</header>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,8 @@ async function save() {
|
||||
title: title.value.trim(),
|
||||
notes: notes.value.trim() || undefined,
|
||||
boardId,
|
||||
personId:
|
||||
typeof route.query.personId === 'string' ? route.query.personId : undefined,
|
||||
});
|
||||
await router.replace({ name: 'project-card-detail', params: { id: data.id } });
|
||||
} else {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatMoney as formatMoneyLib } from '@/lib/money';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import type {
|
||||
CompanyDto,
|
||||
CreateQuoteRequest,
|
||||
@@ -148,9 +149,16 @@ async function load() {
|
||||
} else if (fromDealId) {
|
||||
dealId.value = fromDealId;
|
||||
title.value = 'پیشفاکتور';
|
||||
} else if (!lines.value.length) {
|
||||
} else {
|
||||
if (typeof route.query.personId === 'string' && route.query.personId) {
|
||||
personId.value = route.query.personId;
|
||||
const person = people.value.find((p) => p.id === personId.value);
|
||||
if (person?.company?.id) companyId.value = person.company.id;
|
||||
}
|
||||
if (!lines.value.length) {
|
||||
lines.value = [newLine()];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
error.value = 'بارگذاری فرم پیشفاکتور ناموفق بود.';
|
||||
} finally {
|
||||
@@ -257,7 +265,7 @@ onMounted(load);
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>اعتبار تا</label>
|
||||
<input v-model="validUntil" type="date" />
|
||||
<JalaliDateInput v-model="validUntil" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>ارز</label>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatMoney } from '@/lib/money';
|
||||
import { formatLocalDateInput } from '@/lib/jalali';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import type { OrganizationDto, ReportsDashboardDto } from '@mento/shared';
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -23,8 +25,8 @@ const typeLabel: Record<string, string> = {
|
||||
function defaultRange() {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
from.value = start.toISOString().slice(0, 10);
|
||||
to.value = now.toISOString().slice(0, 10);
|
||||
from.value = formatLocalDateInput(start);
|
||||
to.value = formatLocalDateInput(now);
|
||||
}
|
||||
|
||||
function money(n: number) {
|
||||
@@ -76,11 +78,11 @@ onMounted(() => {
|
||||
<form class="toolbar" @submit.prevent="load">
|
||||
<label>
|
||||
از
|
||||
<input v-model="from" type="date" />
|
||||
<JalaliDateInput v-model="from" />
|
||||
</label>
|
||||
<label>
|
||||
تا
|
||||
<input v-model="to" type="date" />
|
||||
<JalaliDateInput v-model="to" />
|
||||
</label>
|
||||
<button class="btn" type="submit" :disabled="loading">اعمال</button>
|
||||
<a
|
||||
@@ -242,12 +244,8 @@ h1 {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.toolbar input {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
.toolbar :deep(.trigger) {
|
||||
min-width: 11.5rem;
|
||||
}
|
||||
|
||||
.portal-link {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canReassignRecords, canViewAllRecords } from '@/lib/roles';
|
||||
import { formatMoney as formatMoneyLib } from '@/lib/money';
|
||||
import { formatLocalDateInput } from '@/lib/jalali';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import type {
|
||||
CreateSalesGoalRequest,
|
||||
OrgUserDto,
|
||||
@@ -32,13 +34,11 @@ const showAllHint = computed(() => canViewAllRecords(auth.user?.role));
|
||||
|
||||
function monthBounds() {
|
||||
const now = new Date();
|
||||
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
const end = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0),
|
||||
);
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
return {
|
||||
start: start.toISOString().slice(0, 10),
|
||||
end: end.toISOString().slice(0, 10),
|
||||
start: formatLocalDateInput(start),
|
||||
end: formatLocalDateInput(end),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -222,11 +222,11 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>از تاریخ</label>
|
||||
<input v-model="periodStart" type="date" required />
|
||||
<JalaliDateInput v-model="periodStart" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>تا تاریخ</label>
|
||||
<input v-model="periodEnd" type="date" required />
|
||||
<JalaliDateInput v-model="periodEnd" required />
|
||||
</div>
|
||||
<div class="field wide">
|
||||
<label>یادداشت</label>
|
||||
|
||||
@@ -150,6 +150,11 @@ async function load() {
|
||||
assigneeId.value = auth.user?.id ?? '';
|
||||
csatScore.value = null;
|
||||
csatComment.value = '';
|
||||
if (typeof route.query.personId === 'string' && route.query.personId) {
|
||||
personId.value = route.query.personId;
|
||||
const person = people.value.find((p) => p.id === personId.value);
|
||||
if (person?.company?.id) companyId.value = person.company.id;
|
||||
}
|
||||
void loadFaqHints();
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
|
||||
+12
-8
@@ -187,7 +187,7 @@
|
||||
### بالای منو و پایین منو
|
||||
|
||||
- **کنار برند منتو:** آیکون اعلان + شمارنده خواندهنشده
|
||||
- **پایین منو:** نام کاربر و نقش در یک ردیف + دکمه **خروج**
|
||||
- **پایین منو:** کارت پروفایل (نام، نقش، آنلاین) — با کلیک، پروفایل خودتان باز میشود + دکمه **خروج**
|
||||
|
||||
اگر نقش شما فروش یا پشتیبانی باشد، ممکن است ببینید: «فقط پروندههای خودتان را میبینید.»
|
||||
|
||||
@@ -273,16 +273,20 @@
|
||||
5. اگر فیلد سفارشی تعریف شده، پایین فرم پر کنید.
|
||||
6. **ذخیره** را بزنید.
|
||||
|
||||
روی پرونده ذخیرهشده:
|
||||
روی نام شخص در لیست، **پروفایل مشتری** باز میشود (خلاصه هوشمند، تبها، معاملات و تایملاین). برای ویرایش فیلدها از منوی سهنقطه → **ویرایش اطلاعات** استفاده کنید.
|
||||
|
||||
- بخش **باشگاه مشتریان** امتیاز و سطح را نشان میدهد (لینک به مدیریت باشگاه).
|
||||
- پایین صفحه **تایملاین** ظاهر میشود.
|
||||
روی پرونده:
|
||||
|
||||
- تماس / پیام / پیگیری از ردیف اکشنها
|
||||
- تبهای خلاصه، فعالیتها، معاملات، کارتها، اطلاعات
|
||||
- پایین صفحه: فعالیت جدید، ایجاد سریع، تماس سریع
|
||||
- بخش **باشگاه مشتریان** در تب اطلاعات (لینک به مدیریت باشگاه)
|
||||
|
||||
### ویرایش یا حذف شخص
|
||||
|
||||
1. در لیست روی نام کلیک کنید.
|
||||
2. ویرایش → **ذخیره**.
|
||||
3. برای حذف: **حذف** و تأیید (مراقب باشید؛ ارتباطها تحت تأثیر قرار میگیرند).
|
||||
1. در لیست روی نام کلیک کنید تا پروفایل باز شود.
|
||||
2. منوی سهنقطه → **ویرایش اطلاعات** → ذخیره.
|
||||
3. برای حذف: همان منو → **حذف شخص** و تأیید (مراقب باشید؛ ارتباطها تحت تأثیر قرار میگیرند).
|
||||
|
||||
### ثبت و ویرایش شرکت
|
||||
|
||||
@@ -413,7 +417,7 @@
|
||||
|
||||
## ۷) تایملاین پرونده
|
||||
|
||||
روی پرونده **ذخیرهشده** شخص، شرکت یا معامله، پایین فرم بخش **تایملاین** هست.
|
||||
روی پروفایل **شخص** تایملاین در تب خلاصه و فعالیتهاست. روی پرونده **شرکت** یا **معامله**، پایین فرم بخش **تایملاین** هست.
|
||||
|
||||
نشان میدهد:
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# وضعیت پیشرفت منتو (Mento CRM)
|
||||
|
||||
سند زندهٔ کارها — با هر قابلیت جدید همین فایل بهروز میشود.
|
||||
آخرین بهروزرسانی: ۸ شهریور ۱۴۰۵
|
||||
آخرین بهروزرسانی: ۱۱ شهریور ۱۴۰۵
|
||||
|
||||
**راهنما:**
|
||||
- `[x]` انجام شده
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
| تاریخ | مورد |
|
||||
|---|---|
|
||||
| ۱۴۰۵/۶/۱۱ | پروفایل مشتری مطابق ماکاپ (خلاصه هوشمند، تبها، KPI، تایملاین) + پروفایل کاربر از کارت سایدبار |
|
||||
| ۱۴۰۵/۶/۸ | تکمیل کامل راهنمای کاربری (همه ماژولها، منوی جدید، پرتال/SMS، پروژه، باشگاه، اتوماسیون) |
|
||||
| ۱۴۰۵/۶/۸ | کاریز پروژه + باشگاه مشتریان + اتوماسیون اگر/آنگاه |
|
||||
| ۱۴۰۵/۶/۸ | تکمیل `deploy/DEPLOY.md`: پورت DB ۱۵۴۳۲، Prisma از `apps/api`، symlink `@mento/shared`، SMS ترِز، تلههای tarball/nginx |
|
||||
@@ -44,7 +45,7 @@
|
||||
| وضعیت | مورد | توضیح |
|
||||
|---|---|---|
|
||||
| [x] | ورود / نشست / سازمان | حساب دمو و JWT |
|
||||
| [x] | اشخاص و شرکتها | جستجو + جلوگیری از تکراری |
|
||||
| [x] | اشخاص و شرکتها | جستجو + جلوگیری از تکراری؛ پروفایل مشتری با خلاصه هوشمند |
|
||||
| [x] | فیلد سفارشی | تعریف + مقدار روی شخص/شرکت/معامله |
|
||||
| [x] | کاریز کانبان | کشیدن معامله بین مراحل |
|
||||
| [x] | چند کاریز | انتخاب کاریز + ساخت کاریز جدید |
|
||||
|
||||
@@ -318,6 +318,7 @@ export interface MoveDealRequest {
|
||||
|
||||
export type ActivityType = "call" | "meeting" | "task" | "note";
|
||||
export type ActivityStatus = "open" | "done" | "canceled";
|
||||
export type ActivityPriority = "very_low" | "low" | "medium" | "high" | "very_high";
|
||||
export type ActivityFilter = "today" | "overdue" | "upcoming" | "done" | "all";
|
||||
|
||||
export type CustomFieldEntity = "person" | "company" | "deal";
|
||||
@@ -366,14 +367,18 @@ export interface ActivityDto {
|
||||
id: string;
|
||||
type: ActivityType;
|
||||
status: ActivityStatus;
|
||||
priority: ActivityPriority;
|
||||
title: string;
|
||||
description: string | null;
|
||||
dueAt: string | null;
|
||||
durationMinutes: number | null;
|
||||
reminderAt: string | null;
|
||||
completedAt: string | null;
|
||||
person: PersonSummaryDto | null;
|
||||
company: CompanySummaryDto | null;
|
||||
deal: DealSummaryDto | null;
|
||||
assignee: UserSummaryDto | null;
|
||||
createdBy: UserSummaryDto | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -381,8 +386,11 @@ export interface ActivityDto {
|
||||
export interface CreateActivityRequest {
|
||||
title: string;
|
||||
type?: ActivityType;
|
||||
priority?: ActivityPriority;
|
||||
description?: string;
|
||||
dueAt?: string | null;
|
||||
durationMinutes?: number | null;
|
||||
reminderAt?: string | null;
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
dealId?: string | null;
|
||||
@@ -393,8 +401,11 @@ export interface UpdateActivityRequest {
|
||||
title?: string;
|
||||
type?: ActivityType;
|
||||
status?: ActivityStatus;
|
||||
priority?: ActivityPriority;
|
||||
description?: string | null;
|
||||
dueAt?: string | null;
|
||||
durationMinutes?: number | null;
|
||||
reminderAt?: string | null;
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
dealId?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user