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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 21:29:00 +03:30
co-authored by Cursor
parent 574feedfa5
commit f884c60cab
39 changed files with 8255 additions and 441 deletions
+1
View File
@@ -15,3 +15,4 @@ docs/~$*
~$* ~$*
.idea/ .idea/
.vscode/ .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");
@@ -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);
+19 -4
View File
@@ -33,6 +33,14 @@ enum ActivityStatus {
canceled canceled
} }
enum ActivityPriority {
low
medium
high
very_low
very_high
}
enum CustomFieldEntity { enum CustomFieldEntity {
person person
company company
@@ -165,6 +173,7 @@ model User {
ownedCompanies Company[] @relation("CompanyOwner") ownedCompanies Company[] @relation("CompanyOwner")
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner") ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
activities Activity[] @relation("ActivityAssignee") activities Activity[] @relation("ActivityAssignee")
createdActivities Activity[] @relation("ActivityCreator")
notes Note[] @relation("NoteAuthor") notes Note[] @relation("NoteAuthor")
noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader") noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader")
notifications Notification[] notifications Notification[]
@@ -333,10 +342,13 @@ model Activity {
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
type ActivityType @default(task) type ActivityType @default(task)
status ActivityStatus @default(open) status ActivityStatus @default(open)
title String priority ActivityPriority @default(medium)
description String? title String
dueAt DateTime? description String?
completedAt DateTime? dueAt DateTime?
durationMinutes Int?
reminderAt DateTime?
completedAt DateTime?
personId String? personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
companyId String? companyId String?
@@ -345,11 +357,14 @@ model Activity {
deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull) deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
assigneeId String? assigneeId String?
assignee User? @relation("ActivityAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([organizationId, status, dueAt]) @@index([organizationId, status, dueAt])
@@index([organizationId, assigneeId]) @@index([organizationId, assigneeId])
@@index([organizationId, createdById])
@@index([personId]) @@index([personId])
@@index([companyId]) @@index([companyId])
@@index([dealId]) @@index([dealId])
@@ -22,9 +22,12 @@ type ActivityRow = {
id: string; id: string;
type: ActivityDto['type']; type: ActivityDto['type'];
status: ActivityDto['status']; status: ActivityDto['status'];
priority: ActivityDto['priority'];
title: string; title: string;
description: string | null; description: string | null;
dueAt: Date | null; dueAt: Date | null;
durationMinutes: number | null;
reminderAt: Date | null;
completedAt: Date | null; completedAt: Date | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@@ -32,6 +35,7 @@ type ActivityRow = {
company: { id: string; name: string } | null; company: { id: string; name: string } | null;
deal: { id: string; title: string } | null; deal: { id: string; title: string } | null;
assignee: { id: string; fullName: string } | null; assignee: { id: string; fullName: string } | null;
createdBy: { id: string; fullName: string } | null;
}; };
const include = { const include = {
@@ -39,6 +43,7 @@ const include = {
company: { select: { id: true, name: true } }, company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } }, deal: { select: { id: true, title: true } },
assignee: { select: { id: true, fullName: true } }, assignee: { select: { id: true, fullName: true } },
createdBy: { select: { id: true, fullName: true } },
} satisfies Prisma.ActivityInclude; } satisfies Prisma.ActivityInclude;
@Injectable() @Injectable()
@@ -173,12 +178,16 @@ export class ActivitiesService {
organizationId, organizationId,
title: dto.title.trim(), title: dto.title.trim(),
type: dto.type ?? 'task', type: dto.type ?? 'task',
priority: dto.priority ?? 'medium',
description: emptyToNull(dto.description), description: emptyToNull(dto.description),
dueAt: dto.dueAt ? new Date(dto.dueAt) : null, 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), personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null), companyId: emptyToNull(dto.companyId ?? null),
dealId: emptyToNull(dto.dealId ?? null), dealId: emptyToNull(dto.dealId ?? null),
assigneeId, assigneeId,
createdById: userId,
}, },
include, include,
}); });
@@ -211,8 +220,11 @@ export class ActivitiesService {
const data: Prisma.ActivityUncheckedUpdateInput = {}; const data: Prisma.ActivityUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim(); if (dto.title !== undefined) data.title = dto.title.trim();
if (dto.type !== undefined) data.type = dto.type; 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 ('description' in dto) data.description = emptyToNull(dto.description);
if ('dueAt' in dto) data.dueAt = dto.dueAt ? new Date(dto.dueAt) : null; 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 ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null); if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null); if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
@@ -394,9 +406,12 @@ export class ActivitiesService {
id: row.id, id: row.id,
type: row.type, type: row.type,
status: row.status, status: row.status,
priority: row.priority,
title: row.title, title: row.title,
description: row.description, description: row.description,
dueAt: row.dueAt?.toISOString() ?? null, dueAt: row.dueAt?.toISOString() ?? null,
durationMinutes: row.durationMinutes,
reminderAt: row.reminderAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null, completedAt: row.completedAt?.toISOString() ?? null,
person: row.person person: row.person
? { ? {
@@ -407,6 +422,7 @@ export class ActivitiesService {
company: row.company, company: row.company,
deal: row.deal, deal: row.deal,
assignee: row.assignee, assignee: row.assignee,
createdBy: row.createdBy,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(), updatedAt: row.updatedAt.toISOString(),
}; };
+33
View File
@@ -14,6 +14,7 @@ import {
const ACTIVITY_TYPES = ['call', 'meeting', 'task', 'note'] as const; const ACTIVITY_TYPES = ['call', 'meeting', 'task', 'note'] as const;
const ACTIVITY_STATUSES = ['open', 'done', 'canceled'] 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; const ACTIVITY_FILTERS = ['today', 'overdue', 'upcoming', 'done', 'all'] as const;
export class ListActivitiesQueryDto { export class ListActivitiesQueryDto {
@@ -71,6 +72,10 @@ export class CreateActivityDto {
@IsIn(ACTIVITY_TYPES) @IsIn(ACTIVITY_TYPES)
type?: (typeof ACTIVITY_TYPES)[number]; type?: (typeof ACTIVITY_TYPES)[number];
@IsOptional()
@IsIn(ACTIVITY_PRIORITIES)
priority?: (typeof ACTIVITY_PRIORITIES)[number];
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2000) @MaxLength(2000)
@@ -81,6 +86,18 @@ export class CreateActivityDto {
@IsDateString() @IsDateString()
dueAt?: string | null; 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() @IsOptional()
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
@@ -117,6 +134,10 @@ export class UpdateActivityDto {
@IsIn(ACTIVITY_STATUSES) @IsIn(ACTIVITY_STATUSES)
status?: (typeof ACTIVITY_STATUSES)[number]; status?: (typeof ACTIVITY_STATUSES)[number];
@IsOptional()
@IsIn(ACTIVITY_PRIORITIES)
priority?: (typeof ACTIVITY_PRIORITIES)[number];
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2000) @MaxLength(2000)
@@ -127,6 +148,18 @@ export class UpdateActivityDto {
@IsDateString() @IsDateString()
dueAt?: string | null; 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() @IsOptional()
@ValidateIf((_, v) => v !== '' && v != null) @ValidateIf((_, v) => v !== '' && v != null)
@IsString() @IsString()
-1
View File
@@ -9,7 +9,6 @@
"target": "ES2021", "target": "ES2021",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"baseUrl": "./",
"incremental": true, "incremental": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, watch } from 'vue'; import { onMounted, ref, watch } from 'vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import type { import type {
CustomFieldDefinitionDto, CustomFieldDefinitionDto,
CustomFieldEntity, CustomFieldEntity,
@@ -104,12 +105,11 @@ onMounted(load);
" "
/> />
<input <JalaliDateInput
v-else-if="field.fieldType === 'date'" v-else-if="field.fieldType === 'date'"
:id="`cf-${field.key}`" :id="`cf-${field.key}`"
type="date" :model-value="String(displayValue(field.key))"
:value="String(displayValue(field.key))" @update:model-value="setValue(field.key, $event || null)"
@input="setValue(field.key, ($event.target as HTMLInputElement).value)"
/> />
<input <input
@@ -160,8 +160,7 @@ h3 {
select, select,
input[type='text'], input[type='text'],
input[type='number'], input[type='number'] {
input[type='date'] {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px; border-radius: 12px;
padding: 0.8rem 0.95rem; padding: 0.8rem 0.95rem;
+575
View File
@@ -0,0 +1,575 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import {
addJalaliMonths,
dateToJalali,
formatJalaliNumeric,
formatLocalDateInput,
formatLocalDateTimeInput,
jalaliMonthLength,
jalaliMonthName,
jalaliToDate,
parseLocalInput,
toFaDigits,
} from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
const model = defineModel<string>({ default: '' });
const props = withDefaults(
defineProps<{
mode?: 'date' | 'datetime' | 'time';
id?: string;
required?: boolean;
disabled?: boolean;
placeholder?: string;
clearable?: boolean;
icon?: 'calendar' | 'clock' | 'bell';
}>(),
{
mode: 'date',
required: false,
disabled: false,
clearable: true,
icon: undefined,
},
);
const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] as const;
const open = ref(false);
const triggerRef = ref<HTMLButtonElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const top = ref(0);
const left = ref(0);
const cursor = ref(dateToJalali(new Date()));
const draftHour = ref(9);
const draftMinute = ref(0);
type Cell = {
key: string;
jy: number;
jm: number;
jd: number;
inMonth: boolean;
isToday: boolean;
isSelected: boolean;
};
const parsed = computed(() => (props.mode === 'time' ? null : parseLocalInput(model.value)));
const triggerIcon = computed(() => {
if (props.icon) return props.icon;
if (props.mode === 'time') return 'clock';
if (props.icon === 'bell') return 'bell';
return 'calendar';
});
const display = computed(() => {
if (props.mode === 'time') {
const match = /^(\d{1,2}):(\d{2})$/.exec(model.value);
if (!match) return '';
return `${toFaDigits(match[1].padStart(2, '0'))}:${toFaDigits(match[2])}`;
}
if (!parsed.value) return '';
const date = formatJalaliNumeric(parsed.value);
if (props.mode === 'date') return date;
const hh = String(parsed.value.getHours()).padStart(2, '0');
const mm = String(parsed.value.getMinutes()).padStart(2, '0');
return `${date} ${toFaDigits(hh)}:${toFaDigits(mm)}`;
});
const emptyLabel = computed(
() =>
props.placeholder ||
(props.mode === 'datetime'
? 'انتخاب تاریخ و ساعت'
: props.mode === 'time'
? 'انتخاب ساعت'
: 'انتخاب تاریخ'),
);
const monthTitle = computed(
() => `${jalaliMonthName(cursor.value.jm)} ${toFaDigits(cursor.value.jy)}`,
);
const hours = Array.from({ length: 24 }, (_, i) => i);
const minutes = computed(() => {
const steps = Array.from({ length: 12 }, (_, i) => i * 5);
if (!steps.includes(draftMinute.value)) {
return [...steps, draftMinute.value].sort((a, b) => a - b);
}
return steps;
});
const cells = computed<Cell[]>(() => {
const { jy, jm } = cursor.value;
const first = jalaliToDate(jy, jm, 1);
const firstDow = (first.getDay() + 1) % 7;
const today = dateToJalali(new Date());
const selected = parsed.value ? dateToJalali(parsed.value) : null;
const out: Cell[] = [];
const prev = addJalaliMonths(jy, jm, -1);
const prevLen = jalaliMonthLength(prev.jy, prev.jm);
for (let i = 0; i < firstDow; i += 1) {
const jd = prevLen - firstDow + i + 1;
out.push(makeCell(prev.jy, prev.jm, jd, false, today, selected));
}
const len = jalaliMonthLength(jy, jm);
for (let jd = 1; jd <= len; jd += 1) {
out.push(makeCell(jy, jm, jd, true, today, selected));
}
const next = addJalaliMonths(jy, jm, 1);
let n = 1;
while (out.length % 7 !== 0) {
out.push(makeCell(next.jy, next.jm, n, false, today, selected));
n += 1;
}
return out;
});
function makeCell(
jy: number,
jm: number,
jd: number,
inMonth: boolean,
today: { jy: number; jm: number; jd: number },
selected: { jy: number; jm: number; jd: number } | null,
): Cell {
return {
key: `${jy}-${jm}-${jd}`,
jy,
jm,
jd,
inMonth,
isToday: today.jy === jy && today.jm === jm && today.jd === jd,
isSelected: Boolean(selected && selected.jy === jy && selected.jm === jm && selected.jd === jd),
};
}
function emitDate(d: Date) {
model.value = props.mode === 'datetime' ? formatLocalDateTimeInput(d) : formatLocalDateInput(d);
}
function emitTime() {
const pad = (n: number) => String(n).padStart(2, '0');
model.value = `${pad(draftHour.value)}:${pad(draftMinute.value)}`;
}
function syncDraftFrom(d: Date | null) {
if (props.mode === 'time') {
const match = /^(\d{1,2}):(\d{2})$/.exec(model.value);
draftHour.value = match ? Number(match[1]) : 10;
draftMinute.value = match ? Number(match[2]) : 30;
return;
}
const source = d ?? new Date();
const j = dateToJalali(source);
cursor.value = { jy: j.jy, jm: j.jm };
draftHour.value = props.mode === 'datetime' ? source.getHours() : 9;
draftMinute.value = props.mode === 'datetime' ? source.getMinutes() : 0;
}
function place() {
const trigger = triggerRef.value;
const panel = panelRef.value;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width = panel?.offsetWidth || 320;
const height = panel?.offsetHeight || 360;
let nextLeft = rect.left;
if (getComputedStyle(document.documentElement).direction === 'rtl') {
nextLeft = rect.right - width;
}
nextLeft = Math.min(Math.max(8, nextLeft), window.innerWidth - width - 8);
let nextTop = rect.bottom + 8;
if (nextTop + height > window.innerHeight - 8) {
nextTop = Math.max(8, rect.top - height - 8);
}
left.value = nextLeft;
top.value = nextTop;
}
function onWindowChange() {
if (open.value) place();
}
async function show() {
if (props.disabled) return;
syncDraftFrom(parsed.value);
open.value = true;
await nextTick();
place();
await nextTick();
place();
}
function hide() {
open.value = false;
}
function toggle() {
if (open.value) hide();
else void show();
}
function goMonth(delta: number) {
const next = addJalaliMonths(cursor.value.jy, cursor.value.jm, delta);
cursor.value = next;
void nextTick(place);
}
function pickDay(cell: Cell) {
if (!cell.inMonth) {
cursor.value = { jy: cell.jy, jm: cell.jm };
}
const d = jalaliToDate(cell.jy, cell.jm, cell.jd);
if (props.mode === 'datetime') {
d.setHours(draftHour.value, draftMinute.value, 0, 0);
emitDate(d);
return;
}
emitDate(d);
hide();
}
function applyTime() {
if (props.mode === 'time') {
emitTime();
return;
}
const source = parsed.value ?? jalaliToDate(cursor.value.jy, cursor.value.jm, 1);
const j = dateToJalali(source);
const d = jalaliToDate(j.jy, j.jm, j.jd);
d.setHours(draftHour.value, draftMinute.value, 0, 0);
emitDate(d);
}
function pickToday() {
const d = new Date();
if (props.mode === 'time') {
draftHour.value = d.getHours();
draftMinute.value = d.getMinutes();
emitTime();
return;
}
if (props.mode === 'date') {
d.setHours(0, 0, 0, 0);
}
syncDraftFrom(d);
emitDate(d);
if (props.mode === 'date') hide();
}
function clear() {
model.value = '';
hide();
}
function onDocPointer(ev: PointerEvent) {
const target = ev.target as Node | null;
if (!target) return;
if (triggerRef.value?.contains(target) || panelRef.value?.contains(target)) return;
hide();
}
function onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape' && open.value) {
ev.stopPropagation();
ev.preventDefault();
hide();
}
}
watch(open, (value) => {
if (value) {
window.addEventListener('resize', onWindowChange);
window.addEventListener('scroll', onWindowChange, true);
document.addEventListener('pointerdown', onDocPointer, true);
window.addEventListener('keydown', onKeydown, true);
return;
}
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
document.removeEventListener('pointerdown', onDocPointer, true);
window.removeEventListener('keydown', onKeydown, true);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
document.removeEventListener('pointerdown', onDocPointer, true);
window.removeEventListener('keydown', onKeydown, true);
});
</script>
<template>
<div class="wrap" :class="{ disabled }">
<button
:id="id"
ref="triggerRef"
class="trigger"
type="button"
:disabled="disabled"
:aria-expanded="open"
:aria-required="required || undefined"
@click="toggle"
>
<span :class="{ empty: !display }">{{ display || emptyLabel }}</span>
<UiIcon :name="triggerIcon" :size="18" />
</button>
<Teleport to="body">
<div
v-if="open"
id="jalali-picker"
ref="panelRef"
class="panel"
role="dialog"
:style="{ top: `${top}px`, left: `${left}px` }"
>
<header v-if="mode !== 'time'" class="nav">
<button type="button" class="nav-btn" aria-label="ماه بعد" @click="goMonth(1)">
<UiIcon name="chevron" :size="16" />
</button>
<strong>{{ monthTitle }}</strong>
<button type="button" class="nav-btn next" aria-label="ماه قبل" @click="goMonth(-1)">
<UiIcon name="chevron" :size="16" />
</button>
</header>
<template v-if="mode !== 'time'">
<div class="week">
<span v-for="day in WEEKDAYS" :key="day">{{ day }}</span>
</div>
<div class="grid">
<button
v-for="cell in cells"
:key="cell.key"
type="button"
class="day"
:class="{ mute: !cell.inMonth, today: cell.isToday, on: cell.isSelected }"
@click="pickDay(cell)"
>
{{ toFaDigits(cell.jd) }}
</button>
</div>
</template>
<div v-if="mode === 'datetime' || mode === 'time'" class="time">
<label>
ساعت
<select v-model.number="draftHour" @change="applyTime">
<option v-for="h in hours" :key="h" :value="h">{{ toFaDigits(String(h).padStart(2, '0')) }}</option>
</select>
</label>
<label>
دقیقه
<select v-model.number="draftMinute" @change="applyTime">
<option v-for="m in minutes" :key="m" :value="m">{{ toFaDigits(String(m).padStart(2, '0')) }}</option>
</select>
</label>
</div>
<footer class="foot">
<button type="button" class="link" @click="pickToday">امروز</button>
<button
v-if="clearable && !required && model"
type="button"
class="link"
@click="clear"
>
پاک کردن
</button>
<button type="button" class="done" @click="hide">تأیید</button>
</footer>
</div>
</Teleport>
</div>
</template>
<style scoped>
.wrap {
position: relative;
width: 100%;
min-width: 0;
}
.trigger {
width: 100%;
min-height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
border: 1px solid var(--line, #e2e8f0);
border-radius: 12px;
padding: 0.7rem 0.85rem;
background: #fff;
color: #0f172a;
font: inherit;
text-align: start;
cursor: pointer;
}
.trigger:focus {
outline: 2px solid var(--accent-soft, #ccfbf1);
border-color: var(--accent, #0d9488);
}
.trigger span {
min-width: 0;
}
.trigger .empty {
color: #94a3b8;
}
.trigger :deep(.ico) {
color: #0d9488;
flex-shrink: 0;
}
.disabled .trigger {
opacity: 0.55;
cursor: not-allowed;
}
.panel {
position: fixed;
z-index: 120;
width: min(20.5rem, calc(100vw - 16px));
background: #fff;
border-radius: 16px;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
padding: 0.75rem 0.8rem 0.7rem;
font-family: IRANSans, Tahoma, sans-serif;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.55rem;
}
.nav strong {
font-size: 0.92rem;
}
.nav-btn {
width: 32px;
height: 32px;
border: 0;
border-radius: 8px;
background: #f1f5f9;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.nav-btn.next :deep(.ico) {
transform: scaleX(-1);
}
.week,
.grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.15rem;
}
.week span {
text-align: center;
color: #94a3b8;
font-size: 0.72rem;
padding-bottom: 0.25rem;
}
.day {
height: 34px;
border: 0;
border-radius: 9px;
background: none;
color: #0f172a;
font: inherit;
cursor: pointer;
}
.day.mute {
color: #cbd5e1;
}
.day.today {
box-shadow: inset 0 0 0 1px #99f6e4;
}
.day.on,
.day:hover {
background: #0d9488;
color: #fff;
}
.day.mute:hover {
background: #f1f5f9;
color: #64748b;
}
.time {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin-top: 0.65rem;
}
.time label {
display: grid;
gap: 0.28rem;
color: #64748b;
font-size: 0.75rem;
}
.time select {
width: 100%;
min-height: 38px;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0.3rem 0.5rem;
background: #fff;
font: inherit;
}
.foot {
display: flex;
align-items: center;
gap: 0.55rem;
margin-top: 0.7rem;
}
.link,
.done {
border: 0;
background: none;
font: inherit;
cursor: pointer;
}
.link {
color: #0f766e;
font-size: 0.8rem;
font-weight: 700;
}
.done {
margin-inline-start: auto;
background: #14b8a6;
color: #fff;
border-radius: 10px;
padding: 0.4rem 0.8rem;
font-weight: 800;
font-size: 0.8rem;
}
</style>
@@ -2,6 +2,7 @@
import { computed, reactive, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import type { NoteAiPayloadDto, NoteAiSuggestionDto } from '@mento/shared'; import type { NoteAiPayloadDto, NoteAiSuggestionDto } from '@mento/shared';
const props = defineProps<{ const props = defineProps<{
@@ -190,7 +191,7 @@ function formatPayloadSummary(value: NoteAiPayloadDto | null) {
کار بعدی کار بعدی
</label> </label>
<input v-model="nextTitle" type="text" placeholder="عنوان کار بعدی" /> <input v-model="nextTitle" type="text" placeholder="عنوان کار بعدی" />
<input v-model="nextDueAt" type="datetime-local" /> <JalaliDateInput v-model="nextDueAt" mode="datetime" />
</div> </div>
<div v-if="memoryItems.length" class="block"> <div v-if="memoryItems.length" class="block">
@@ -275,7 +276,6 @@ header {
} }
input[type='text'], input[type='text'],
input[type='datetime-local'],
textarea { textarea {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 10px; border-radius: 10px;
+131 -1
View File
@@ -12,6 +12,7 @@ defineProps<{
| 'sparkle' | 'sparkle'
| 'bell' | 'bell'
| 'calendar' | 'calendar'
| 'calendar-plus'
| 'mail' | 'mail'
| 'megaphone' | 'megaphone'
| 'send' | 'send'
@@ -19,7 +20,31 @@ defineProps<{
| 'warning' | 'warning'
| 'trend' | 'trend'
| 'phone' | '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; size?: number;
}>(); }>();
</script> </script>
@@ -88,6 +113,22 @@ defineProps<{
<rect x="3.5" y="5" width="17" height="15" rx="2" /> <rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" /> <path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
</template> </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'"> <template v-else-if="name === 'mail'">
<rect x="3" y="5" width="18" height="14" rx="2" /> <rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m4 7 8 6 8-6" /> <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="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" /> <path d="M17 6h2.5A2.5 2.5 0 0 1 17 8.5" />
</template> </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> </svg>
</template> </template>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { personInitials } from '@/lib/person-insights';
const props = withDefaults(
defineProps<{
name?: string | null;
src?: string | null;
size?: number;
}>(),
{
name: '',
src: '/brand/user-avatar.png',
size: 28,
},
);
const initials = computed(() => personInitials(props.name));
const failed = ref(false);
</script>
<template>
<span class="user-avatar" :style="{ width: `${size}px`, height: `${size}px` }" :title="name || undefined">
<img
v-if="src && !failed"
:src="src"
:alt="name || 'آواتار'"
@error="failed = true"
/>
<em v-else>{{ initials }}</em>
</span>
</template>
<style scoped>
.user-avatar {
display: inline-grid;
place-items: center;
border-radius: 999px;
overflow: hidden;
flex-shrink: 0;
background: linear-gradient(145deg, #5eead4, #0f766e);
box-shadow: 0 0 0 2px #fff, 0 0 0 3px #ccfbf1;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
em {
font-style: normal;
color: #fff;
font-weight: 800;
font-size: 0.62rem;
line-height: 1;
}
</style>
@@ -0,0 +1,897 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { api } from '@/lib/api';
import {
calendarDaysBetween,
formatClockFa,
formatJalaliDay,
toFaDigits,
} from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import type { ActivityDto, ActivityType, OrganizationDto, OrgUserDto } from '@mento/shared';
const props = defineProps<{
activity: ActivityDto;
saving?: boolean;
}>();
const emit = defineEmits<{
close: [];
submit: [text: string];
}>();
const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
call: 'phone',
meeting: 'users',
task: 'check',
note: 'chat',
};
const comment = ref('');
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const orgName = ref('سازمان');
const users = ref<OrgUserDto[]>([]);
const mentionOpen = ref(false);
const voiceTip = ref(localStorage.getItem('mento.voiceTipSeen') !== '1');
const voiceError = ref('');
const recording = ref(false);
const seconds = ref(0);
const pullY = ref(0);
let recognition: SpeechRecognitionLike | null = null;
let timer: number | null = null;
let snapshot = '';
let finals = '';
let keepOnEnd = true;
let pendingSubmit = false;
let pulling = false;
let pullStart = 0;
type SpeechRecognitionLike = {
lang: string;
continuous: boolean;
interimResults: boolean;
start: () => void;
stop: () => void;
abort: () => void;
onresult: ((ev: SpeechRecognitionEventLike) => void) | null;
onerror: ((ev: { error?: string }) => void) | null;
onend: (() => void) | null;
};
type SpeechRecognitionEventLike = {
resultIndex: number;
results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }>;
};
const subtitle = computed(() => {
const a = props.activity;
return a.deal?.title || a.company?.name || a.person?.fullName || '';
});
const dueLabel = computed(() => {
const due = props.activity.dueAt;
if (!due) return 'بدون موعد';
const d = new Date(due);
const days = calendarDaysBetween(d, new Date());
const clock = formatClockFa(d);
if (days === 0) return `امروز ${clock}`;
if (days === 1) return `دیروز ${clock}`;
if (days === -1) return `فردا ${clock}`;
return `${formatJalaliDay(d)} ${clock}`;
});
const timerLabel = computed(() => {
const m = String(Math.floor(seconds.value / 60)).padStart(2, '0');
const s = String(seconds.value % 60).padStart(2, '0');
return `${toFaDigits(m)}:${toFaDigits(s)}`;
});
const canSubmit = computed(() => comment.value.trim().length > 0 && !props.saving);
function speechCtor(): (new () => SpeechRecognitionLike) | null {
const w = window as Window & {
SpeechRecognition?: new () => SpeechRecognitionLike;
webkitSpeechRecognition?: new () => SpeechRecognitionLike;
};
return w.SpeechRecognition || w.webkitSpeechRecognition || null;
}
function applyTranscript(interim = '') {
const extra = `${finals}${interim}`.trim();
comment.value = extra ? `${snapshot}${snapshot && !snapshot.endsWith(' ') ? ' ' : ''}${extra}` : snapshot;
}
function startTimer() {
stopTimer();
seconds.value = 0;
timer = window.setInterval(() => {
seconds.value += 1;
}, 1000);
}
function stopTimer() {
if (timer != null) {
window.clearInterval(timer);
timer = null;
}
}
function stopRecognition(keep: boolean) {
keepOnEnd = keep;
if (!recognition) {
finishRecording(keep);
return;
}
try {
if (keep) recognition.stop();
else recognition.abort();
} catch {
finishRecording(keep);
return;
}
window.setTimeout(() => {
if (recording.value) finishRecording(keepOnEnd);
}, 400);
}
function finishRecording(keep: boolean) {
if (!recording.value && !recognition) return;
stopTimer();
recording.value = false;
pullY.value = 0;
recognition = null;
if (!keep) {
comment.value = snapshot;
pendingSubmit = false;
} else {
applyTranscript('');
}
finals = '';
if (pendingSubmit) {
pendingSubmit = false;
submit();
}
}
function startRecording() {
voiceError.value = '';
mentionOpen.value = false;
if (voiceTip.value) {
voiceTip.value = false;
localStorage.setItem('mento.voiceTipSeen', '1');
}
const Ctor = speechCtor();
if (!Ctor) {
voiceError.value = 'تایپ صوتی در این مرورگر پشتیبانی نمی‌شود.';
return;
}
snapshot = comment.value;
finals = '';
keepOnEnd = true;
const rec = new Ctor();
rec.lang = 'fa-IR';
rec.continuous = true;
rec.interimResults = true;
rec.onresult = (ev) => {
let interim = '';
for (let i = ev.resultIndex; i < ev.results.length; i += 1) {
const piece = ev.results[i];
if (!piece) continue;
const text = piece[0]?.transcript ?? '';
if (piece.isFinal) finals += `${text} `;
else interim += text;
}
applyTranscript(interim);
};
rec.onerror = (ev) => {
if (ev.error === 'aborted' || ev.error === 'no-speech') return;
voiceError.value = 'ضبط صدا ناموفق بود. دوباره تلاش کنید.';
finishRecording(true);
};
rec.onend = () => {
finishRecording(keepOnEnd);
};
recognition = rec;
recording.value = true;
startTimer();
try {
rec.start();
} catch {
voiceError.value = 'شروع ضبط صدا ممکن نشد.';
finishRecording(false);
}
}
function discardRecording() {
pendingSubmit = false;
stopRecognition(false);
}
function keepRecording() {
pendingSubmit = false;
stopRecognition(true);
}
function sendRecording() {
pendingSubmit = true;
stopRecognition(true);
}
function submit() {
if (recording.value) keepRecording();
const text = comment.value.trim();
if (!text || props.saving) return;
emit('submit', text);
}
function close() {
if (recording.value) discardRecording();
emit('close');
}
function insertMention(name: string) {
const token = `@${name} `;
const el = textareaRef.value;
if (!el) {
comment.value += token;
mentionOpen.value = false;
return;
}
const start = el.selectionStart ?? comment.value.length;
const end = el.selectionEnd ?? start;
comment.value = `${comment.value.slice(0, start)}${token}${comment.value.slice(end)}`;
mentionOpen.value = false;
void nextTick(() => {
el.focus();
const pos = start + token.length;
el.setSelectionRange(pos, pos);
});
}
function onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape') {
ev.preventDefault();
if (mentionOpen.value) {
mentionOpen.value = false;
return;
}
if (recording.value) {
discardRecording();
return;
}
close();
}
}
function onPullStart(ev: PointerEvent) {
if (!recording.value) return;
const target = ev.target as HTMLElement | null;
if (target?.closest('button')) return;
pulling = true;
pullStart = ev.clientY;
pullY.value = 0;
(ev.currentTarget as HTMLElement | null)?.setPointerCapture?.(ev.pointerId);
}
function onPullMove(ev: PointerEvent) {
if (!pulling) return;
pullY.value = Math.max(0, ev.clientY - pullStart);
}
function onPullEnd() {
if (!pulling) return;
const shouldCancel = pullY.value > 72;
pulling = false;
pullY.value = 0;
if (shouldCancel) discardRecording();
}
onMounted(async () => {
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', onKeydown);
await nextTick();
textareaRef.value?.focus();
try {
const [{ data: org }, usersRes] = await Promise.all([
api.get<OrganizationDto>('/organizations/current'),
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
]);
if (org.name) orgName.value = org.name;
users.value = usersRes.data;
if (!users.value.length && props.activity.assignee) {
users.value = [
{
id: props.activity.assignee.id,
email: '',
fullName: props.activity.assignee.fullName,
role: 'sales',
createdAt: '',
},
];
}
} catch {
orgName.value = 'سازمان';
}
});
onBeforeUnmount(() => {
document.body.style.overflow = '';
window.removeEventListener('keydown', onKeydown);
pendingSubmit = false;
stopTimer();
if (recognition) {
try {
recognition.abort();
} catch {
/* ignore */
}
recognition = null;
}
recording.value = false;
});
</script>
<template>
<Teleport to="body">
<div class="layer" role="presentation">
<button class="backdrop" type="button" aria-label="بستن" @click="close" />
<section
class="sheet"
role="dialog"
aria-modal="true"
aria-labelledby="comment-sheet-title"
>
<div class="handle" aria-hidden="true" />
<header class="head">
<button class="icon" type="button" aria-label="ثبت نظر" :disabled="!canSubmit" @click="submit">
<UiIcon name="check-plain" :size="22" />
</button>
<h2 id="comment-sheet-title">ثبت نظر</h2>
<button class="icon" type="button" aria-label="بستن" @click="close">
<UiIcon name="x" :size="20" />
</button>
</header>
<div class="body">
<article class="ctx">
<div class="ctx-copy">
<h3>{{ activity.title }}</h3>
<p v-if="subtitle" class="sub">
<UiIcon name="building" :size="14" />
{{ subtitle }}
</p>
<div class="meta">
<span>
<UiIcon name="user" :size="14" />
{{ activity.assignee?.fullName || 'بدون مسئول' }}
</span>
<i />
<span>
<UiIcon name="calendar" :size="14" />
{{ dueLabel }}
</span>
</div>
</div>
<span class="type" :class="activity.type">
<UiIcon :name="typeIcon[activity.type]" :size="22" />
</span>
</article>
<div class="editor">
<textarea
ref="textareaRef"
v-model="comment"
:disabled="recording"
rows="6"
placeholder="متن خود را بنویسید..."
/>
<div class="tools">
<div class="tool-wrap">
<button
class="tool"
type="button"
aria-label="منشن"
:disabled="recording"
@click="mentionOpen = !mentionOpen"
>
@
</button>
<ul v-if="mentionOpen" class="mentions">
<li v-if="!users.length" class="empty">کاربری برای منشن یافت نشد.</li>
<li v-for="user in users" :key="user.id">
<button type="button" @click="insertMention(user.fullName)">
{{ user.fullName }}
</button>
</li>
</ul>
</div>
<div class="tool-wrap">
<p v-if="voiceTip && !recording" class="tip">تایپ صوتی را امتحان کنید! 🎙️✨</p>
<button
class="tool mic"
type="button"
aria-label="تایپ صوتی"
:class="{ on: recording }"
@click="recording ? keepRecording() : startRecording()"
>
<UiIcon name="mic" :size="18" />
</button>
</div>
</div>
</div>
<p v-if="voiceError" class="voice-err">{{ voiceError }}</p>
<p class="notice">
<UiIcon name="lightbulb" :size="18" />
یادداشت شما فقط برای اعضای تیم و مدیران {{ orgName }} قابل مشاهده خواهد بود.
</p>
<div v-if="!recording" class="actions">
<button class="act primary" type="button" :disabled="!canSubmit" @click="submit">
ثبت نظر
</button>
<button class="act ghost" type="button" :disabled="saving" @click="close">انصراف</button>
</div>
<div
v-else
class="recorder"
:style="{ transform: pullY ? `translateY(${pullY}px)` : undefined }"
@pointerdown="onPullStart"
@pointermove="onPullMove"
@pointerup="onPullEnd"
@pointercancel="onPullEnd"
>
<div class="rec-top">
<strong>در حال ضبط صدا...</strong>
<span>{{ timerLabel }}</span>
</div>
<div class="rec-controls">
<button class="rec-side" type="button" aria-label="ارسال نظر" @click="sendRecording">
<UiIcon name="send" :size="18" />
</button>
<button class="rec-stop" type="button" aria-label="توقف ضبط" @click="keepRecording">
<UiIcon name="stop" :size="22" />
</button>
<button class="rec-side" type="button" aria-label="حذف ضبط" @click="discardRecording">
<UiIcon name="trash" :size="18" />
</button>
</div>
<p class="pull">
برای لغو، بیرون بکشید
<UiIcon name="chevron" :size="14" />
</p>
</div>
</div>
</section>
</div>
</Teleport>
</template>
<style scoped>
.layer {
position: fixed;
inset: 0;
z-index: 90;
display: flex;
justify-content: center;
align-items: stretch;
}
.backdrop {
display: none;
}
.sheet {
width: 100%;
height: 100dvh;
background: #fff;
display: flex;
flex-direction: column;
font-family: IRANSans, Tahoma, sans-serif;
}
.handle {
width: 42px;
height: 4px;
margin: 0.55rem auto 0.15rem;
border-radius: 999px;
background: #d4d4d8;
}
.head {
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
padding: 0.15rem 0.7rem 0.45rem;
}
.head h2 {
margin: 0;
text-align: center;
font-size: 1.05rem;
font-weight: 800;
color: #0f172a;
}
.icon {
width: 40px;
height: 40px;
border: 0;
background: none;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.icon:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.body {
flex: 1;
overflow: auto;
padding: 0.35rem 1rem calc(1rem + env(safe-area-inset-bottom));
display: grid;
align-content: start;
gap: 0.85rem;
}
.ctx {
display: flex;
gap: 0.8rem;
align-items: flex-start;
padding: 0.9rem 0.95rem;
border: 1px solid #e2e8f0;
border-radius: 18px;
background: #fff;
}
.type {
width: 46px;
height: 46px;
border-radius: 999px;
display: grid;
place-items: center;
flex-shrink: 0;
color: #fff;
}
.type.call {
background: #0d9488;
}
.type.meeting {
background: #7c3aed;
}
.type.task {
background: #ea580c;
}
.type.note {
background: #2563eb;
}
.ctx-copy {
min-width: 0;
flex: 1;
}
.ctx-copy h3 {
margin: 0.1rem 0 0;
font-size: 0.98rem;
font-weight: 800;
line-height: 1.55;
}
.sub,
.meta span {
display: flex;
align-items: center;
gap: 0.3rem;
color: #64748b;
font-size: 0.8rem;
}
.sub {
margin: 0.28rem 0 0;
}
.meta {
display: flex;
align-items: center;
gap: 0.7rem;
margin-top: 0.7rem;
padding-top: 0.65rem;
border-top: 1px solid #f1f5f9;
}
.meta i {
width: 1px;
height: 14px;
background: #e2e8f0;
}
.editor {
border: 1px solid #e2e8f0;
border-radius: 16px;
background: #fff;
min-height: 11.5rem;
display: flex;
flex-direction: column;
}
.editor textarea {
flex: 1;
width: 100%;
min-height: 8.5rem;
border: 0;
resize: none;
padding: 0.9rem 0.95rem 0.2rem;
font: inherit;
font-size: 0.95rem;
line-height: 1.9;
color: #0f172a;
background: transparent;
}
.editor textarea:focus {
outline: none;
}
.editor textarea::placeholder {
color: #94a3b8;
}
.tools {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 0.35rem 0.5rem 0.5rem;
}
.tool-wrap {
position: relative;
}
.tool {
width: 36px;
height: 36px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
color: #0f766e;
display: grid;
place-items: center;
font: inherit;
font-weight: 800;
cursor: pointer;
}
.tool.mic {
color: #0d9488;
}
.tool.on,
.tool.mic:hover {
background: #ecfdf5;
}
.tip {
position: absolute;
inset-inline-end: 0;
bottom: calc(100% + 8px);
margin: 0;
white-space: nowrap;
background: #111827;
color: #fff;
font-size: 0.74rem;
font-weight: 700;
padding: 0.42rem 0.65rem;
border-radius: 10px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18);
}
.tip::after {
content: '';
position: absolute;
inset-inline-end: 14px;
top: 100%;
border: 6px solid transparent;
border-top-color: #111827;
}
.mentions {
position: absolute;
inset-inline-start: 0;
bottom: calc(100% + 6px);
margin: 0;
padding: 0.3rem;
list-style: none;
min-width: 11.5rem;
max-height: 12rem;
overflow: auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.16);
z-index: 2;
}
.mentions button,
.mentions .empty {
width: 100%;
border: 0;
background: none;
text-align: start;
font: inherit;
padding: 0.55rem 0.65rem;
border-radius: 8px;
color: #0f172a;
cursor: pointer;
}
.mentions .empty {
color: #94a3b8;
cursor: default;
}
.mentions button:hover {
background: #f1f5f9;
}
.voice-err {
margin: -0.35rem 0 0;
color: #b91c1c;
font-size: 0.8rem;
}
.notice {
margin: 0;
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.72rem 0.8rem;
border-radius: 12px;
background: #ecfdf5;
color: #0f766e;
font-size: 0.8rem;
line-height: 1.7;
}
.notice :deep(.ico) {
margin-top: 0.12rem;
flex-shrink: 0;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.65rem;
}
.act {
min-height: 48px;
border-radius: 14px;
font: inherit;
font-weight: 800;
cursor: pointer;
}
.act.primary {
border: 0;
background: #14b8a6;
color: #fff;
}
.act.ghost {
border: 1.5px solid #14b8a6;
background: #fff;
color: #0f766e;
}
.act:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.recorder {
border: 1.5px dashed #cbd5e1;
border-radius: 16px;
padding: 0.95rem 0.85rem 0.7rem;
text-align: center;
touch-action: none;
}
.rec-top {
display: flex;
justify-content: space-between;
align-items: center;
color: #334155;
font-size: 0.92rem;
}
.rec-top strong {
font-weight: 800;
}
.rec-top span {
font-variant-numeric: tabular-nums;
color: #64748b;
}
.rec-controls {
display: flex;
align-items: center;
justify-content: space-between;
margin: 1rem 1.4rem 0.55rem;
}
.rec-side {
width: 42px;
height: 42px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.rec-stop {
width: 68px;
height: 68px;
border: 0;
border-radius: 999px;
background: #14b8a6;
color: #fff;
display: grid;
place-items: center;
box-shadow: 0 10px 24px rgba(20, 184, 166, 0.35);
cursor: pointer;
}
.pull {
margin: 0.35rem 0 0;
display: inline-flex;
align-items: center;
gap: 0.2rem;
color: #94a3b8;
font-size: 0.78rem;
}
.pull :deep(.ico) {
transform: rotate(90deg);
}
@media (min-width: 900px) {
.layer {
background: rgba(15, 23, 42, 0.28);
align-items: center;
padding: 1.2rem;
}
.backdrop {
display: block;
position: absolute;
inset: 0;
border: 0;
background: transparent;
cursor: pointer;
}
.sheet {
position: relative;
max-width: 480px;
height: min(92dvh, 860px);
border-radius: 22px;
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22);
}
}
</style>
@@ -0,0 +1,458 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import {
formatClockFa,
formatJalaliDayClock,
startOfLocalDay,
} from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
type PostponeKey = 'm15' | 'm30' | 'h1' | 'tomorrow-am' | 'tomorrow-pm' | 'custom';
const props = defineProps<{
dueAt?: string | null;
saving?: boolean;
}>();
const emit = defineEmits<{
close: [];
submit: [iso: string];
}>();
const nowTick = ref(Date.now());
const selected = ref<PostponeKey | null>(null);
const customLocal = ref('');
let tickTimer: number | null = null;
const base = computed(() => {
const now = new Date(nowTick.value);
const due = props.dueAt ? new Date(props.dueAt) : now;
if (Number.isNaN(due.getTime()) || due.getTime() <= now.getTime()) return now;
return due;
});
function addMinutes(mins: number) {
return new Date(base.value.getTime() + mins * 60_000);
}
function tomorrowAt(hour: number, minute = 0) {
const d = startOfLocalDay(new Date(nowTick.value));
d.setDate(d.getDate() + 1);
d.setHours(hour, minute, 0, 0);
return d;
}
const options = computed(() => {
const m15 = addMinutes(15);
const m30 = addMinutes(30);
const h1 = addMinutes(60);
const am = tomorrowAt(10);
const pm = tomorrowAt(13);
return [
{
key: 'm15' as const,
icon: 'refresh' as const,
label: 'یک ربع دیرتر',
time: formatClockFa(m15),
at: m15,
},
{
key: 'm30' as const,
icon: 'refresh' as const,
label: 'نیم ساعت دیرتر',
time: formatClockFa(m30),
at: m30,
},
{
key: 'h1' as const,
icon: 'refresh' as const,
label: 'یک ساعت دیرتر',
time: formatClockFa(h1),
at: h1,
},
{
key: 'tomorrow-am' as const,
icon: 'calendar-sun' as const,
label: 'فردا صبح',
time: `فردا ${formatClockFa(am)}`,
at: am,
},
{
key: 'tomorrow-pm' as const,
icon: 'calendar-sun' as const,
label: 'فردا بعد از ظهر',
time: `فردا ${formatClockFa(pm)}`,
at: pm,
},
];
});
function toLocalInput(d: Date) {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
const customDate = computed(() => {
if (!customLocal.value) return null;
const d = new Date(customLocal.value);
return Number.isNaN(d.getTime()) ? null : d;
});
const customPreview = computed(() =>
customDate.value ? formatJalaliDayClock(customDate.value) : 'تاریخ و ساعت را انتخاب کنید',
);
const canSubmit = computed(() => {
if (props.saving || !selected.value) return false;
if (selected.value === 'custom') return Boolean(customDate.value);
return true;
});
function chosenDate(): Date | null {
if (selected.value === 'custom') return customDate.value;
return options.value.find((item) => item.key === selected.value)?.at ?? null;
}
function pick(key: PostponeKey) {
selected.value = key;
if (key === 'custom' && !customLocal.value) {
customLocal.value = toLocalInput(addMinutes(60));
}
}
function submit() {
const at = chosenDate();
if (!at || props.saving) return;
emit('submit', at.toISOString());
}
function close() {
emit('close');
}
function onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape') {
ev.preventDefault();
close();
}
}
onMounted(() => {
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', onKeydown);
tickTimer = window.setInterval(() => {
nowTick.value = Date.now();
}, 15_000);
});
onBeforeUnmount(() => {
document.body.style.overflow = '';
window.removeEventListener('keydown', onKeydown);
if (tickTimer != null) window.clearInterval(tickTimer);
});
</script>
<template>
<Teleport to="body">
<div class="layer" role="presentation">
<button class="backdrop" type="button" aria-label="بستن" @click="close" />
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="postpone-title">
<div class="handle" aria-hidden="true" />
<header class="head">
<button class="icon" type="button" aria-label="تأیید تعویق" :disabled="!canSubmit" @click="submit">
<UiIcon name="check-plain" :size="22" />
</button>
<div class="titles">
<h2 id="postpone-title">به تعویق انداختن فعالیت</h2>
</div>
<button class="icon" type="button" aria-label="بستن" @click="close">
<UiIcon name="x" :size="20" />
</button>
</header>
<p class="lead">زمان جدید برای این فعالیت را انتخاب کنید.</p>
<div class="body">
<button
v-for="item in options"
:key="item.key"
class="card"
type="button"
:class="{ on: selected === item.key }"
:disabled="saving"
@click="pick(item.key)"
>
<span class="label">{{ item.label }}</span>
<span class="aside">
<span class="ico-box">
<UiIcon :name="item.icon" :size="20" />
</span>
<span class="when">{{ item.time }}</span>
</span>
</button>
<button
class="card custom"
type="button"
:class="{ on: selected === 'custom' }"
:disabled="saving"
@click="pick('custom')"
>
<span class="copy">
<strong>تنظیم زمان دلخواه</strong>
<small>انتخاب تاریخ و ساعت</small>
</span>
<span class="aside">
<span class="ico-box">
<UiIcon name="calendar-edit" :size="20" />
</span>
</span>
</button>
<div v-if="selected === 'custom'" class="custom-box">
<label class="picker">
<span>تاریخ و ساعت جدید</span>
<JalaliDateInput v-model="customLocal" mode="datetime" />
</label>
<p class="preview">{{ customPreview }}</p>
</div>
<p class="notice">
<UiIcon name="lightbulb" :size="18" />
با تعویق انداختن، این فعالیت در زمان جدید به لیست فعالیت‌های شما منتقل می‌شود.
</p>
</div>
</section>
</div>
</Teleport>
</template>
<style scoped>
.layer {
position: fixed;
inset: 0;
z-index: 90;
display: flex;
justify-content: center;
align-items: stretch;
}
.backdrop {
display: none;
}
.sheet {
width: 100%;
height: 100dvh;
background: #fff;
display: flex;
flex-direction: column;
font-family: IRANSans, Tahoma, sans-serif;
}
.handle {
width: 42px;
height: 4px;
margin: 0.55rem auto 0.15rem;
border-radius: 999px;
background: #d4d4d8;
}
.head {
display: grid;
grid-template-columns: 40px 1fr 40px;
align-items: center;
padding: 0.1rem 0.7rem 0.15rem;
}
.titles h2,
.lead {
margin: 0;
text-align: center;
}
.titles h2 {
font-size: 1.02rem;
font-weight: 800;
color: #0f172a;
}
.lead {
color: #94a3b8;
font-size: 0.82rem;
padding: 0 1.2rem 0.55rem;
}
.icon {
width: 40px;
height: 40px;
border: 0;
background: none;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.icon:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.body {
flex: 1;
overflow: auto;
padding: 0.15rem 1rem calc(1rem + env(safe-area-inset-bottom));
display: grid;
align-content: start;
gap: 0.55rem;
}
.card {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
min-height: 72px;
padding: 0.7rem 0.9rem;
border: 1px solid #e8eef3;
border-radius: 16px;
background: #fff;
font: inherit;
text-align: start;
cursor: pointer;
}
.card.on {
border-color: #99f6e4;
background: #f0fdfa;
box-shadow: 0 0 0 1px #99f6e4;
}
.card:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.label,
.copy strong {
font-size: 0.95rem;
font-weight: 800;
color: #0f172a;
}
.copy {
display: grid;
gap: 0.15rem;
}
.copy small {
color: #94a3b8;
font-size: 0.78rem;
}
.aside {
display: grid;
justify-items: center;
gap: 0.28rem;
min-width: 3.6rem;
flex-shrink: 0;
}
.ico-box {
width: 40px;
height: 40px;
border-radius: 10px;
background: #e6f7f4;
color: #0d9488;
display: grid;
place-items: center;
}
.when {
color: #0d9488;
font-size: 0.74rem;
font-weight: 800;
white-space: nowrap;
}
.custom-box {
border: 1px solid #e8eef3;
border-radius: 16px;
padding: 0.85rem 0.9rem;
display: grid;
gap: 0.55rem;
}
.picker {
display: grid;
gap: 0.4rem;
color: #64748b;
font-size: 0.8rem;
}
.picker input {
width: 100%;
min-height: 44px;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 0.45rem 0.7rem;
font: inherit;
color: #0f172a;
background: #fff;
}
.preview {
margin: 0;
color: #0f766e;
font-size: 0.82rem;
font-weight: 700;
}
.notice {
margin: 0.35rem 0 0;
display: flex;
align-items: flex-start;
gap: 0.55rem;
padding: 0.85rem 0.9rem;
border-radius: 14px;
background: #f8fafc;
color: #475569;
font-size: 0.8rem;
line-height: 1.75;
}
.notice :deep(.ico) {
margin-top: 0.15rem;
color: #0d9488;
flex-shrink: 0;
}
@media (min-width: 900px) {
.layer {
background: rgba(15, 23, 42, 0.28);
align-items: center;
padding: 1.2rem;
}
.backdrop {
display: block;
position: absolute;
inset: 0;
border: 0;
background: transparent;
cursor: pointer;
}
.sheet {
position: relative;
max-width: 480px;
height: min(92dvh, 860px);
border-radius: 22px;
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22);
}
}
</style>
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router';
import type { RouteLocationRaw } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
withDefaults(
defineProps<{
title?: string;
description?: string;
primaryLabel: string;
primaryTo: RouteLocationRaw;
secondaryLabel?: string;
secondaryTo?: RouteLocationRaw;
secondaryIcon?: 'calendar' | 'calendar-plus' | 'user-plus';
}>(),
{
title: 'هنوز اطلاعاتی ثبت نشده!',
description:
'برای شروع، اطلاعات اولیه مشتری را ثبت کنید یا اولین فعالیت خود را اضافه نمایید.',
secondaryIcon: 'calendar-plus',
},
);
</script>
<template>
<div class="empty-state">
<div class="art" aria-hidden="true">
<span class="spark s1">✦</span>
<span class="spark s2">✦</span>
<span class="spark s3">✦</span>
<svg viewBox="0 0 168 132" fill="none">
<path
d="M26 44c0-7.2 5.8-13 13-13h30l9 9h51c7.2 0 13 5.8 13 13v54c0 7.2-5.8 13-13 13H39c-7.2 0-13-5.8-13-13V44Z"
fill="#D9E6E8"
/>
<path
d="M26 50h116v47c0 7.2-5.8 13-13 13H39c-7.2 0-13-5.8-13-13V50Z"
fill="#E8F1F2"
/>
<rect x="54" y="34" width="60" height="74" rx="11" fill="#fff" />
<rect x="54" y="34" width="60" height="74" rx="11" stroke="#E2E8F0" />
<circle cx="84" cy="60" r="12" fill="#C5D4DA" />
<path
d="M66 96c2.2-9.2 8.4-14 18-14s15.8 4.8 18 14"
stroke="#C5D4DA"
stroke-width="9"
stroke-linecap="round"
/>
<circle cx="122" cy="100" r="17" fill="#14b8a6" />
<circle cx="122" cy="100" r="17" fill="#14b8a6" stroke="#fff" stroke-width="3" />
<path d="M122 92.5v15M114.5 100h15" stroke="#fff" stroke-width="2.8" stroke-linecap="round" />
</svg>
</div>
<h3>{{ title }}</h3>
<p>{{ description }}</p>
<div class="actions">
<RouterLink class="btn-primary" :to="primaryTo">
<span class="plus">+</span>
{{ primaryLabel }}
</RouterLink>
<RouterLink v-if="secondaryLabel && secondaryTo" class="btn-secondary" :to="secondaryTo">
<UiIcon :name="secondaryIcon" :size="18" />
{{ secondaryLabel }}
</RouterLink>
</div>
</div>
</template>
<style scoped>
.empty-state {
display: grid;
justify-items: center;
align-content: center;
text-align: center;
width: 100%;
padding: 1.4rem 0.8rem 0.6rem;
}
.art {
position: relative;
width: 168px;
margin-bottom: 1.05rem;
}
.art svg {
width: 168px;
height: auto;
display: block;
}
.spark {
position: absolute;
color: #5eead4;
font-size: 0.68rem;
line-height: 1;
}
.s1 {
top: 6px;
inset-inline-end: 8px;
}
.s2 {
top: 44px;
inset-inline-start: 2px;
font-size: 0.52rem;
color: #99f6e4;
}
.s3 {
bottom: 20px;
inset-inline-end: -2px;
font-size: 0.46rem;
color: #99f6e4;
}
h3 {
margin: 0;
font-size: 1.08rem;
font-weight: 800;
color: #1e2937;
}
p {
margin: 0.5rem 0 1.25rem;
max-width: 21.5rem;
color: #94a3b8;
font-size: 0.84rem;
line-height: 1.85;
}
.actions {
display: grid;
gap: 0.7rem;
width: min(100%, 304px);
}
.btn-primary,
.btn-secondary {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 48px;
border-radius: 16px;
font-weight: 700;
font-size: 0.9rem;
}
.btn-primary {
background: #14b8a6;
color: #fff;
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.26);
}
.plus {
font-size: 1.2rem;
line-height: 1;
font-weight: 600;
}
.btn-secondary {
background: #fff;
color: #0f766e;
border: 1px solid #e2e8f0;
}
@media (max-width: 599px) {
.empty-state {
padding: 1.2rem 0.2rem 0.4rem;
}
.art,
.art svg {
width: 152px;
}
h3 {
font-size: 1.02rem;
}
p {
font-size: 0.8rem;
margin-bottom: 1.15rem;
}
.btn-primary,
.btn-secondary {
min-height: 46px;
font-size: 0.86rem;
border-radius: 14px;
}
}
</style>
@@ -0,0 +1,215 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import type { RouteLocationRaw } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
export type ProfileMoreItem = {
key: string;
label: string;
to?: RouteLocationRaw;
danger?: boolean;
};
const open = defineModel<boolean>({ default: false });
defineProps<{ items: ProfileMoreItem[] }>();
const emit = defineEmits<{ select: [key: string] }>();
const triggerRef = ref<HTMLButtonElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const top = ref(0);
const left = ref(0);
function place() {
const trigger = triggerRef.value;
const panel = panelRef.value;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width = panel?.offsetWidth || 220;
const height = panel?.offsetHeight || 0;
const rtl = getComputedStyle(document.documentElement).direction === 'rtl';
let nextLeft = rtl ? rect.right - width : rect.left;
nextLeft = Math.min(Math.max(8, nextLeft), window.innerWidth - width - 8);
let nextTop = rect.bottom + 8;
if (nextTop + height > window.innerHeight - 8) {
nextTop = Math.max(8, rect.top - height - 8);
}
left.value = nextLeft;
top.value = nextTop;
}
function onWindowChange() {
if (open.value) place();
}
watch(open, async (value) => {
if (!value) {
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
return;
}
await nextTick();
place();
await nextTick();
place();
window.addEventListener('resize', onWindowChange);
window.addEventListener('scroll', onWindowChange, true);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
});
function toggle() {
open.value = !open.value;
}
function close() {
open.value = false;
}
function onSelect(key: string) {
emit('select', key);
nextTick(() => {
close();
});
}
</script>
<template>
<div class="wrap" @click.stop>
<button
ref="triggerRef"
class="icon-btn"
type="button"
aria-label="منوی بیشتر"
:aria-expanded="open"
@click="toggle"
>
<UiIcon name="dots-v" :size="18" />
</button>
<Teleport to="body">
<div v-if="open" class="layer">
<button class="backdrop" type="button" aria-label="بستن منو" @click="close" />
<ul
ref="panelRef"
class="panel"
role="menu"
:style="{ top: `${top}px`, left: `${left}px` }"
@click.stop
>
<li v-for="item in items" :key="item.key" role="none">
<RouterLink
v-if="item.to"
class="item"
:class="{ danger: item.danger }"
role="menuitem"
:to="item.to"
@click.stop="close"
>
{{ item.label }}
</RouterLink>
<button
v-else
class="item"
:class="{ danger: item.danger }"
type="button"
role="menuitem"
@click.stop.prevent="onSelect(item.key)"
>
{{ item.label }}
</button>
</li>
</ul>
</div>
</Teleport>
</div>
</template>
<style scoped>
.wrap {
position: relative;
flex-shrink: 0;
}
.icon-btn {
width: 38px;
height: 38px;
border: 0;
background: #fff;
border-radius: 12px;
display: grid;
place-items: center;
color: #334155;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.05);
cursor: pointer;
}
.layer {
position: fixed;
inset: 0;
z-index: 80;
}
.backdrop {
position: absolute;
inset: 0;
border: 0;
background: transparent;
cursor: default;
}
.panel {
position: absolute;
z-index: 1;
margin: 0;
padding: 0.4rem;
list-style: none;
width: 220px;
background: #fff;
border-radius: 14px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);
display: flex;
flex-direction: column;
gap: 2px;
}
.panel > li {
display: block;
width: 100%;
}
.item {
display: block;
width: 100%;
box-sizing: border-box;
margin: 0;
border: 0;
border-radius: 10px;
background: transparent;
color: #0f172a;
font: inherit;
font-weight: 600;
font-size: 0.9rem;
line-height: 1.6;
text-align: start;
text-decoration: none;
white-space: nowrap;
padding: 0.7rem 0.85rem;
cursor: pointer;
}
.item:hover {
background: #f0fdfa;
}
.item.danger {
color: #b91c1c;
}
.item.danger:hover {
background: #fef2f2;
}
</style>
+55 -26
View File
@@ -24,14 +24,6 @@ const showOwnRecordsBanner = computed(
const roleLabel = computed(() => const roleLabel = computed(() =>
auth.user?.role ? USER_ROLE_LABELS[auth.user.role] : '', 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 todayLabel = computed(() => formatJalaliShort(new Date()));
const salesOpen = ref(false); const salesOpen = ref(false);
@@ -344,8 +336,14 @@ onUnmounted(() => {
</p> </p>
<div class="sidebar-foot"> <div class="sidebar-foot">
<div class="profile-card"> <RouterLink
<span class="avatar">{{ userInitials }}</span> 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"> <div class="profile-meta">
<strong>{{ auth.user?.fullName }}</strong> <strong>{{ auth.user?.fullName }}</strong>
<span>{{ roleLabel || 'کاربر' }}</span> <span>{{ roleLabel || 'کاربر' }}</span>
@@ -354,14 +352,17 @@ onUnmounted(() => {
آنلاین آنلاین
</span> </span>
</div> </div>
</div> </RouterLink>
<div class="promo-card"> <div class="promo-card">
<span class="promo-icons" aria-hidden="true"> <span class="promo-icons" aria-hidden="true">
<UiIcon name="send" :size="16" /> <UiIcon name="send" :size="20" />
<UiIcon name="sparkle" :size="14" /> <UiIcon name="sparkle" :size="18" />
</span> </span>
<p>با منتو فروشات را چند برابر کن</p> <p>
<span>با منتو فروشات را</span>
<span>چند برابر کن</span>
</p>
</div> </div>
<button class="logout-link" type="button" @click="onLogout">خروج از حساب</button> <button class="logout-link" type="button" @click="onLogout">خروج از حساب</button>
@@ -689,29 +690,46 @@ onUnmounted(() => {
.profile-card { .profile-card {
display: flex; display: flex;
flex-direction: row-reverse;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 0.85rem 0.9rem; padding: 0.85rem 0.9rem;
border-radius: 18px; border-radius: 18px;
background: #f8fafc; background: #f8fafc;
color: inherit;
}
.profile-card:hover {
background: #f0fdfa;
}
.profile-card.router-link-active {
background: #ecfdf5;
} }
.avatar { .avatar {
width: 44px; position: relative;
height: 44px; width: 48px;
height: 48px;
border-radius: 999px; border-radius: 999px;
background: linear-gradient(145deg, #99f6e4, #5eead4);
color: #0f766e;
display: grid;
place-items: center;
font-weight: 800;
flex-shrink: 0; 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 { .profile-meta {
min-width: 0; min-width: 0;
flex: 1;
display: grid; display: grid;
gap: 0.12rem; gap: 0.12rem;
text-align: right;
} }
.profile-meta strong { .profile-meta strong {
@@ -729,6 +747,7 @@ onUnmounted(() => {
.online-label { .online-label {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: flex-start;
gap: 0.35rem; gap: 0.35rem;
color: #64748b !important; color: #64748b !important;
font-size: 0.74rem !important; font-size: 0.74rem !important;
@@ -745,7 +764,7 @@ onUnmounted(() => {
.promo-card { .promo-card {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.7rem; gap: 0.75rem;
padding: 1.05rem 1.05rem; padding: 1.05rem 1.05rem;
border-radius: 22px; border-radius: 22px;
background: linear-gradient(105deg, #c4e84a 0%, #2dd4bf 42%, #1d4ed8 100%); background: linear-gradient(105deg, #c4e84a 0%, #2dd4bf 42%, #1d4ed8 100%);
@@ -755,17 +774,27 @@ onUnmounted(() => {
.promo-icons { .promo-icons {
display: inline-flex; display: inline-flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 0.2rem; justify-content: center;
gap: 0.35rem;
flex-shrink: 0; flex-shrink: 0;
opacity: 0.95; opacity: 0.95;
} }
.promo-card p { .promo-card p {
margin: 0; margin: 0;
font-size: 0.86rem; flex: 1;
font-weight: 700; min-width: 0;
line-height: 1.45; font-size: 0.98rem;
font-weight: 800;
line-height: 1.5;
display: grid;
gap: 0.1rem;
}
.promo-card p span {
display: block;
} }
.logout-link { .logout-link {
+84
View File
@@ -176,13 +176,97 @@ export function formatJalaliShort(d: Date): string {
return `${weekdayName(d)} ${toFaDigits(jd)} ${jalaliMonthName(jm)}`; 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 { export function toFaDigits(value: number | string): string {
return String(value).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]!); 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 { export function gregorianDayKey(d: Date): string {
const y = d.getFullYear(); const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0'); const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`; 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'))}`;
}
+17
View File
@@ -22,3 +22,20 @@ export function formatMoney(amount: number, currency = 'IRR'): string {
return `${amount} ${currency}`; 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);
}
+97
View File
@@ -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 };
}
+17 -2
View File
@@ -38,6 +38,11 @@ export const router = createRouter({
name: 'home', name: 'home',
component: () => import('@/views/HomeView.vue'), component: () => import('@/views/HomeView.vue'),
}, },
{
path: 'profile',
name: 'profile',
component: () => import('@/views/ProfileView.vue'),
},
{ {
path: 'reports', path: 'reports',
name: 'reports', name: 'reports',
@@ -78,10 +83,15 @@ export const router = createRouter({
name: 'activity-new', name: 'activity-new',
component: () => import('@/views/ActivityFormView.vue'), component: () => import('@/views/ActivityFormView.vue'),
}, },
{
path: 'activities/:id/edit',
name: 'activity-edit',
component: () => import('@/views/ActivityFormView.vue'),
},
{ {
path: 'activities/:id', path: 'activities/:id',
name: 'activity-detail', name: 'activity-detail',
component: () => import('@/views/ActivityFormView.vue'), component: () => import('@/views/ActivityDetailView.vue'),
}, },
{ {
path: 'quotes', path: 'quotes',
@@ -205,10 +215,15 @@ export const router = createRouter({
name: 'person-new', name: 'person-new',
component: () => import('@/views/PersonFormView.vue'), component: () => import('@/views/PersonFormView.vue'),
}, },
{
path: 'contacts/people/:id/edit',
name: 'person-edit',
component: () => import('@/views/PersonFormView.vue'),
},
{ {
path: 'contacts/people/:id', path: 'contacts/people/:id',
name: 'person-detail', name: 'person-detail',
component: () => import('@/views/PersonFormView.vue'), component: () => import('@/views/PersonProfileView.vue'),
}, },
{ {
path: 'contacts/companies/new', path: 'contacts/companies/new',
File diff suppressed because it is too large Load Diff
+616
View File
@@ -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
+10 -1
View File
@@ -4,6 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'; import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import EntityTimeline from '@/components/EntityTimeline.vue'; import EntityTimeline from '@/components/EntityTimeline.vue';
import { formatMoney } from '@/lib/money'; import { formatMoney } from '@/lib/money';
import type { import type {
@@ -167,6 +168,14 @@ async function load() {
} else { } else {
const firstOpen = stages.value.find((s) => !s.isWon && !s.isLost); const firstOpen = stages.value.find((s) => !s.isWon && !s.isLost);
stageId.value = firstOpen?.id ?? stages.value[0]?.id ?? ''; 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 { } catch {
error.value = 'بارگذاری فرم معامله ناموفق بود.'; error.value = 'بارگذاری فرم معامله ناموفق بود.';
@@ -310,7 +319,7 @@ onMounted(load);
</div> </div>
<div class="field"> <div class="field">
<label for="expectedCloseAt">تاریخ بسته‌شدن مورد انتظار</label> <label for="expectedCloseAt">تاریخ بسته‌شدن مورد انتظار</label>
<input id="expectedCloseAt" v-model="expectedCloseAt" type="date" /> <JalaliDateInput id="expectedCloseAt" v-model="expectedCloseAt" />
</div> </div>
<div class="field wide"> <div class="field wide">
<label for="tags">برچسب‌ها (با کاما جدا کنید)</label> <label for="tags">برچسب‌ها (با کاما جدا کنید)</label>
+7 -5
View File
@@ -4,6 +4,8 @@ import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { formatMoney as formatMoneyLib } from '@/lib/money'; import { formatMoney as formatMoneyLib } from '@/lib/money';
import { formatLocalDateInput } from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import type { import type {
CompanyDto, CompanyDto,
CreateInvoiceRequest, CreateInvoiceRequest,
@@ -66,7 +68,7 @@ const payAmount = ref('');
const payMethod = ref<PaymentMethod>('transfer'); const payMethod = ref<PaymentMethod>('transfer');
const payNotes = ref(''); const payNotes = ref('');
const payInstallmentId = ref(''); const payInstallmentId = ref('');
const payDate = ref(new Date().toISOString().slice(0, 10)); const payDate = ref(formatLocalDateInput(new Date()));
const statusLabel: Record<InvoiceStatus, string> = { const statusLabel: Record<InvoiceStatus, string> = {
draft: 'پیش‌نویس', draft: 'پیش‌نویس',
@@ -137,7 +139,7 @@ function splitInstallments(count: number) {
d.setMonth(d.getMonth() + i + 1); d.setMonth(d.getMonth() + i + 1);
return { return {
key: `split-${i}-${Date.now()}`, key: `split-${i}-${Date.now()}`,
dueDate: d.toISOString().slice(0, 10), dueDate: formatLocalDateInput(d),
amount: String(base + (i === count - 1 ? remainder : 0)), amount: String(base + (i === count - 1 ? remainder : 0)),
}; };
}); });
@@ -414,7 +416,7 @@ onMounted(load);
</div> </div>
<div class="field"> <div class="field">
<label>سررسید</label> <label>سررسید</label>
<input v-model="dueDate" type="date" /> <JalaliDateInput v-model="dueDate" />
</div> </div>
<div class="field"> <div class="field">
<label>ارز</label> <label>ارز</label>
@@ -512,7 +514,7 @@ onMounted(load);
:key="item.key" :key="item.key"
class="installment-row" class="installment-row"
> >
<input v-model="item.dueDate" type="date" /> <JalaliDateInput v-model="item.dueDate" />
<input <input
v-model="item.amount" v-model="item.amount"
type="number" type="number"
@@ -561,7 +563,7 @@ onMounted(load);
{{ label }} {{ label }}
</option> </option>
</select> </select>
<input v-model="payDate" type="date" /> <JalaliDateInput v-model="payDate" />
<select v-model="payInstallmentId"> <select v-model="payInstallmentId">
<option value="">بدون اتصال به قسط</option> <option value="">بدون اتصال به قسط</option>
<option <option
+11 -2
View File
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import NoteAiSuggestionPanel from '@/components/NoteAiSuggestionPanel.vue'; import NoteAiSuggestionPanel from '@/components/NoteAiSuggestionPanel.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import type { import type {
DealDto, DealDto,
NoteAttachmentDto, NoteAttachmentDto,
@@ -15,6 +16,7 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
const router = useRouter(); const router = useRouter();
const route = useRoute();
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const error = ref(''); const error = ref('');
@@ -72,6 +74,13 @@ async function load() {
notes.value = notesRes.data.items; notes.value = notesRes.data.items;
people.value = peopleRes.data.items; people.value = peopleRes.data.items;
deals.value = boardRes.data.stages.flatMap((s) => s.deals); 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) { } catch (e) {
if (axios.isAxiosError(e) && !e.response) { if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.'; error.value = 'اتصال به API برقرار نشد.';
@@ -372,7 +381,7 @@ onMounted(load);
</div> </div>
<div class="field"> <div class="field">
<label>یادآوری</label> <label>یادآوری</label>
<input v-model="draftReminder" type="datetime-local" /> <JalaliDateInput v-model="draftReminder" mode="datetime" />
</div> </div>
</div> </div>
</form> </form>
+5 -2
View File
@@ -165,7 +165,7 @@ async function submit() {
await api.patch(`/people/${id.value}`, payload()); 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> </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> </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(), title: title.value.trim(),
notes: notes.value.trim() || undefined, notes: notes.value.trim() || undefined,
boardId, boardId,
personId:
typeof route.query.personId === 'string' ? route.query.personId : undefined,
}); });
await router.replace({ name: 'project-card-detail', params: { id: data.id } }); await router.replace({ name: 'project-card-detail', params: { id: data.id } });
} else { } else {
+11 -3
View File
@@ -4,6 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { formatMoney as formatMoneyLib } from '@/lib/money'; import { formatMoney as formatMoneyLib } from '@/lib/money';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import type { import type {
CompanyDto, CompanyDto,
CreateQuoteRequest, CreateQuoteRequest,
@@ -148,8 +149,15 @@ async function load() {
} else if (fromDealId) { } else if (fromDealId) {
dealId.value = fromDealId; dealId.value = fromDealId;
title.value = 'پیش‌فاکتور'; title.value = 'پیش‌فاکتور';
} else if (!lines.value.length) { } else {
lines.value = [newLine()]; 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 { } catch {
error.value = 'بارگذاری فرم پیش‌فاکتور ناموفق بود.'; error.value = 'بارگذاری فرم پیش‌فاکتور ناموفق بود.';
@@ -257,7 +265,7 @@ onMounted(load);
</div> </div>
<div class="field"> <div class="field">
<label>اعتبار تا</label> <label>اعتبار تا</label>
<input v-model="validUntil" type="date" /> <JalaliDateInput v-model="validUntil" />
</div> </div>
<div class="field"> <div class="field">
<label>ارز</label> <label>ارز</label>
+8 -10
View File
@@ -3,6 +3,8 @@ import { onMounted, ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money'; import { formatMoney } from '@/lib/money';
import { formatLocalDateInput } from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import type { OrganizationDto, ReportsDashboardDto } from '@mento/shared'; import type { OrganizationDto, ReportsDashboardDto } from '@mento/shared';
const loading = ref(false); const loading = ref(false);
@@ -23,8 +25,8 @@ const typeLabel: Record<string, string> = {
function defaultRange() { function defaultRange() {
const now = new Date(); const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1); const start = new Date(now.getFullYear(), now.getMonth(), 1);
from.value = start.toISOString().slice(0, 10); from.value = formatLocalDateInput(start);
to.value = now.toISOString().slice(0, 10); to.value = formatLocalDateInput(now);
} }
function money(n: number) { function money(n: number) {
@@ -76,11 +78,11 @@ onMounted(() => {
<form class="toolbar" @submit.prevent="load"> <form class="toolbar" @submit.prevent="load">
<label> <label>
از از
<input v-model="from" type="date" /> <JalaliDateInput v-model="from" />
</label> </label>
<label> <label>
تا تا
<input v-model="to" type="date" /> <JalaliDateInput v-model="to" />
</label> </label>
<button class="btn" type="submit" :disabled="loading">اعمال</button> <button class="btn" type="submit" :disabled="loading">اعمال</button>
<a <a
@@ -242,12 +244,8 @@ h1 {
color: var(--muted); color: var(--muted);
} }
.toolbar input { .toolbar :deep(.trigger) {
border: 1px solid var(--line); min-width: 11.5rem;
border-radius: 12px;
padding: 0.55rem 0.7rem;
font: inherit;
background: #fff;
} }
.portal-link { .portal-link {
+8 -8
View File
@@ -6,6 +6,8 @@ import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { canReassignRecords, canViewAllRecords } from '@/lib/roles'; import { canReassignRecords, canViewAllRecords } from '@/lib/roles';
import { formatMoney as formatMoneyLib } from '@/lib/money'; import { formatMoney as formatMoneyLib } from '@/lib/money';
import { formatLocalDateInput } from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import type { import type {
CreateSalesGoalRequest, CreateSalesGoalRequest,
OrgUserDto, OrgUserDto,
@@ -32,13 +34,11 @@ const showAllHint = computed(() => canViewAllRecords(auth.user?.role));
function monthBounds() { function monthBounds() {
const now = new Date(); const now = new Date();
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date( const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0),
);
return { return {
start: start.toISOString().slice(0, 10), start: formatLocalDateInput(start),
end: end.toISOString().slice(0, 10), end: formatLocalDateInput(end),
}; };
} }
@@ -222,11 +222,11 @@ onMounted(() => {
</div> </div>
<div class="field"> <div class="field">
<label>از تاریخ</label> <label>از تاریخ</label>
<input v-model="periodStart" type="date" required /> <JalaliDateInput v-model="periodStart" required />
</div> </div>
<div class="field"> <div class="field">
<label>تا تاریخ</label> <label>تا تاریخ</label>
<input v-model="periodEnd" type="date" required /> <JalaliDateInput v-model="periodEnd" required />
</div> </div>
<div class="field wide"> <div class="field wide">
<label>یادداشت</label> <label>یادداشت</label>
+5
View File
@@ -150,6 +150,11 @@ async function load() {
assigneeId.value = auth.user?.id ?? ''; assigneeId.value = auth.user?.id ?? '';
csatScore.value = null; csatScore.value = null;
csatComment.value = ''; 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(); void loadFaqHints();
} }
} catch { } catch {
+1 -2
View File
@@ -17,8 +17,7 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
}, }
"baseUrl": "."
}, },
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
} }
+12 -8
View File
@@ -187,7 +187,7 @@
### بالای منو و پایین منو ### بالای منو و پایین منو
- **کنار برند منتو:** آیکون اعلان + شمارنده خوانده‌نشده - **کنار برند منتو:** آیکون اعلان + شمارنده خوانده‌نشده
- **پایین منو:** نام کاربر و نقش در یک ردیف + دکمه **خروج** - **پایین منو:** کارت پروفایل (نام، نقش، آنلاین) — با کلیک، پروفایل خودتان باز می‌شود + دکمه **خروج**
اگر نقش شما فروش یا پشتیبانی باشد، ممکن است ببینید: «فقط پرونده‌های خودتان را می‌بینید.» اگر نقش شما فروش یا پشتیبانی باشد، ممکن است ببینید: «فقط پرونده‌های خودتان را می‌بینید.»
@@ -273,16 +273,20 @@
5. اگر فیلد سفارشی تعریف شده، پایین فرم پر کنید. 5. اگر فیلد سفارشی تعریف شده، پایین فرم پر کنید.
6. **ذخیره** را بزنید. 6. **ذخیره** را بزنید.
روی پرونده ذخیره‌شده: روی نام شخص در لیست، **پروفایل مشتری** باز می‌شود (خلاصه هوشمند، تب‌ها، معاملات و تایم‌لاین). برای ویرایش فیلدها از منوی سه‌نقطه → **ویرایش اطلاعات** استفاده کنید.
- بخش **باشگاه مشتریان** امتیاز و سطح را نشان می‌دهد (لینک به مدیریت باشگاه). روی پرونده:
- پایین صفحه **تایم‌لاین** ظاهر می‌شود.
- تماس / پیام / پیگیری از ردیف اکشن‌ها
- تب‌های خلاصه، فعالیت‌ها، معاملات، کارت‌ها، اطلاعات
- پایین صفحه: فعالیت جدید، ایجاد سریع، تماس سریع
- بخش **باشگاه مشتریان** در تب اطلاعات (لینک به مدیریت باشگاه)
### ویرایش یا حذف شخص ### ویرایش یا حذف شخص
1. در لیست روی نام کلیک کنید. 1. در لیست روی نام کلیک کنید تا پروفایل باز شود.
2. ویرایش → **ذخیره**. 2. منوی سه‌نقطه → **ویرایش اطلاعات** → ذخیره.
3. برای حذف: **حذف** و تأیید (مراقب باشید؛ ارتباط‌ها تحت تأثیر قرار می‌گیرند). 3. برای حذف: همان منو → **حذف شخص** و تأیید (مراقب باشید؛ ارتباط‌ها تحت تأثیر قرار می‌گیرند).
### ثبت و ویرایش شرکت ### ثبت و ویرایش شرکت
@@ -413,7 +417,7 @@
## ۷) تایم‌لاین پرونده ## ۷) تایم‌لاین پرونده
روی پرونده **ذخیره‌شده** شخص، شرکت یا معامله، پایین فرم بخش **تایم‌لاین** هست. روی پروفایل **شخص** تایم‌لاین در تب خلاصه و فعالیت‌هاست. روی پرونده **شرکت** یا **معامله**، پایین فرم بخش **تایم‌لاین** هست.
نشان می‌دهد: نشان می‌دهد:
+3 -2
View File
@@ -1,7 +1,7 @@
# وضعیت پیشرفت منتو (Mento CRM) # وضعیت پیشرفت منتو (Mento CRM)
سند زندهٔ کارها — با هر قابلیت جدید همین فایل به‌روز می‌شود. سند زندهٔ کارها — با هر قابلیت جدید همین فایل به‌روز می‌شود.
آخرین به‌روزرسانی: ۸ شهریور ۱۴۰۵ آخرین به‌روزرسانی: ۱۱ شهریور ۱۴۰۵
**راهنما:** **راهنما:**
- `[x]` انجام شده - `[x]` انجام شده
@@ -14,6 +14,7 @@
| تاریخ | مورد | | تاریخ | مورد |
|---|---| |---|---|
| ۱۴۰۵/۶/۱۱ | پروفایل مشتری مطابق ماکاپ (خلاصه هوشمند، تب‌ها، KPI، تایم‌لاین) + پروفایل کاربر از کارت سایدبار |
| ۱۴۰۵/۶/۸ | تکمیل کامل راهنمای کاربری (همه ماژول‌ها، منوی جدید، پرتال/SMS، پروژه، باشگاه، اتوماسیون) | | ۱۴۰۵/۶/۸ | تکمیل کامل راهنمای کاربری (همه ماژول‌ها، منوی جدید، پرتال/SMS، پروژه، باشگاه، اتوماسیون) |
| ۱۴۰۵/۶/۸ | کاریز پروژه + باشگاه مشتریان + اتوماسیون اگر/آنگاه | | ۱۴۰۵/۶/۸ | کاریز پروژه + باشگاه مشتریان + اتوماسیون اگر/آنگاه |
| ۱۴۰۵/۶/۸ | تکمیل `deploy/DEPLOY.md`: پورت DB ۱۵۴۳۲، Prisma از `apps/api`، symlink `@mento/shared`، SMS ترِز، تله‌های tarball/nginx | | ۱۴۰۵/۶/۸ | تکمیل `deploy/DEPLOY.md`: پورت DB ۱۵۴۳۲، Prisma از `apps/api`، symlink `@mento/shared`، SMS ترِز، تله‌های tarball/nginx |
@@ -44,7 +45,7 @@
| وضعیت | مورد | توضیح | | وضعیت | مورد | توضیح |
|---|---|---| |---|---|---|
| [x] | ورود / نشست / سازمان | حساب دمو و JWT | | [x] | ورود / نشست / سازمان | حساب دمو و JWT |
| [x] | اشخاص و شرکت‌ها | جستجو + جلوگیری از تکراری | | [x] | اشخاص و شرکت‌ها | جستجو + جلوگیری از تکراری؛ پروفایل مشتری با خلاصه هوشمند |
| [x] | فیلد سفارشی | تعریف + مقدار روی شخص/شرکت/معامله | | [x] | فیلد سفارشی | تعریف + مقدار روی شخص/شرکت/معامله |
| [x] | کاریز کانبان | کشیدن معامله بین مراحل | | [x] | کاریز کانبان | کشیدن معامله بین مراحل |
| [x] | چند کاریز | انتخاب کاریز + ساخت کاریز جدید | | [x] | چند کاریز | انتخاب کاریز + ساخت کاریز جدید |
+11
View File
@@ -318,6 +318,7 @@ export interface MoveDealRequest {
export type ActivityType = "call" | "meeting" | "task" | "note"; export type ActivityType = "call" | "meeting" | "task" | "note";
export type ActivityStatus = "open" | "done" | "canceled"; 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 ActivityFilter = "today" | "overdue" | "upcoming" | "done" | "all";
export type CustomFieldEntity = "person" | "company" | "deal"; export type CustomFieldEntity = "person" | "company" | "deal";
@@ -366,14 +367,18 @@ export interface ActivityDto {
id: string; id: string;
type: ActivityType; type: ActivityType;
status: ActivityStatus; status: ActivityStatus;
priority: ActivityPriority;
title: string; title: string;
description: string | null; description: string | null;
dueAt: string | null; dueAt: string | null;
durationMinutes: number | null;
reminderAt: string | null;
completedAt: string | null; completedAt: string | null;
person: PersonSummaryDto | null; person: PersonSummaryDto | null;
company: CompanySummaryDto | null; company: CompanySummaryDto | null;
deal: DealSummaryDto | null; deal: DealSummaryDto | null;
assignee: UserSummaryDto | null; assignee: UserSummaryDto | null;
createdBy: UserSummaryDto | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -381,8 +386,11 @@ export interface ActivityDto {
export interface CreateActivityRequest { export interface CreateActivityRequest {
title: string; title: string;
type?: ActivityType; type?: ActivityType;
priority?: ActivityPriority;
description?: string; description?: string;
dueAt?: string | null; dueAt?: string | null;
durationMinutes?: number | null;
reminderAt?: string | null;
personId?: string | null; personId?: string | null;
companyId?: string | null; companyId?: string | null;
dealId?: string | null; dealId?: string | null;
@@ -393,8 +401,11 @@ export interface UpdateActivityRequest {
title?: string; title?: string;
type?: ActivityType; type?: ActivityType;
status?: ActivityStatus; status?: ActivityStatus;
priority?: ActivityPriority;
description?: string | null; description?: string | null;
dueAt?: string | null; dueAt?: string | null;
durationMinutes?: number | null;
reminderAt?: string | null;
personId?: string | null; personId?: string | null;
companyId?: string | null; companyId?: string | null;
dealId?: string | null; dealId?: string | null;